From 4e510bf5c2e1090a971431e8be819df515674bcd Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 4 Mar 2026 22:45:36 +0800 Subject: [PATCH] feat(server): stripe service, more stripe table --- apps/server/drizzle/0002_mean_tigra.sql | 75 + apps/server/drizzle/meta/0002_snapshot.json | 2041 +++++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + apps/server/src/app.ts | 15 +- apps/server/src/routes/stripe.ts | 230 ++- apps/server/src/schemas/index.ts | 1 + apps/server/src/schemas/stripe.ts | 124 ++ apps/server/src/services/stripe.ts | 129 ++ 8 files changed, 2605 insertions(+), 17 deletions(-) create mode 100644 apps/server/drizzle/0002_mean_tigra.sql create mode 100644 apps/server/drizzle/meta/0002_snapshot.json create mode 100644 apps/server/src/schemas/stripe.ts create mode 100644 apps/server/src/services/stripe.ts diff --git a/apps/server/drizzle/0002_mean_tigra.sql b/apps/server/drizzle/0002_mean_tigra.sql new file mode 100644 index 000000000..776cd672d --- /dev/null +++ b/apps/server/drizzle/0002_mean_tigra.sql @@ -0,0 +1,75 @@ +CREATE TABLE "stripe_checkout_session" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "stripe_session_id" text NOT NULL, + "stripe_customer_id" text, + "mode" text NOT NULL, + "status" text, + "payment_status" text, + "amount_total" integer, + "currency" text, + "success_url" text, + "cancel_url" text, + "stripe_payment_intent_id" text, + "stripe_subscription_id" text, + "metadata" text, + "expires_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "stripe_checkout_session_stripe_session_id_unique" UNIQUE("stripe_session_id") +); +--> statement-breakpoint +CREATE TABLE "stripe_customer" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "stripe_customer_id" text NOT NULL, + "email" text, + "name" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "stripe_customer_stripe_customer_id_unique" UNIQUE("stripe_customer_id") +); +--> statement-breakpoint +CREATE TABLE "stripe_invoice" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "stripe_invoice_id" text NOT NULL, + "stripe_customer_id" text, + "stripe_subscription_id" text, + "status" text, + "amount_due" integer, + "amount_paid" integer, + "currency" text, + "invoice_url" text, + "invoice_pdf" text, + "period_start" timestamp, + "period_end" timestamp, + "paid_at" timestamp, + "metadata" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "stripe_invoice_stripe_invoice_id_unique" UNIQUE("stripe_invoice_id") +); +--> statement-breakpoint +CREATE TABLE "stripe_subscription" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "stripe_subscription_id" text NOT NULL, + "stripe_customer_id" text NOT NULL, + "stripe_price_id" text, + "status" text NOT NULL, + "current_period_start" timestamp, + "current_period_end" timestamp, + "cancel_at_period_end" text, + "canceled_at" timestamp, + "ended_at" timestamp, + "metadata" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "stripe_subscription_stripe_subscription_id_unique" UNIQUE("stripe_subscription_id") +); +--> statement-breakpoint +ALTER TABLE "stripe_checkout_session" ADD CONSTRAINT "stripe_checkout_session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "stripe_customer" ADD CONSTRAINT "stripe_customer_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "stripe_invoice" ADD CONSTRAINT "stripe_invoice_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "stripe_subscription" ADD CONSTRAINT "stripe_subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0002_snapshot.json b/apps/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 000000000..79c75c542 --- /dev/null +++ b/apps/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,2041 @@ +{ + "id": "c1d089d5-904c-4404-8f43-d270d0bc42e6", + "prevId": "4d4b5a3a-6b6d-4015-ac4b-fa1d434ff3f8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.avatar_model": { + "name": "avatar_model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "avatar_model_character_id_characters_id_fk": { + "name": "avatar_model_character_id_characters_id_fk", + "tableFrom": "avatar_model", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_role": { + "name": "creator_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_credit": { + "name": "price_credit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bookmarks_count": { + "name": "bookmarks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "interactions_count": { + "name": "interactions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "characters_creator_id_user_id_fk": { + "name": "characters_creator_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "characters_owner_id_user_id_fk": { + "name": "characters_owner_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_capabilities": { + "name": "character_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_capabilities_character_id_characters_id_fk": { + "name": "character_capabilities_character_id_characters_id_fk", + "tableFrom": "character_capabilities", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_covers": { + "name": "character_covers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "foreground_url": { + "name": "foreground_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "background_url": { + "name": "background_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_covers_character_id_characters_id_fk": { + "name": "character_covers_character_id_characters_id_fk", + "tableFrom": "character_covers", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_i18n": { + "name": "character_i18n", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_i18n_character_id_characters_id_fk": { + "name": "character_i18n_character_id_characters_id_fk", + "tableFrom": "character_i18n", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_prompts": { + "name": "character_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_prompts_character_id_characters_id_fk": { + "name": "character_prompts_character_id_characters_id_fk", + "tableFrom": "character_prompts", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_members": { + "name": "chat_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_type": { + "name": "member_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_members_chat_id_chats_id_fk": { + "name": "chat_members_chat_id_chats_id_fk", + "tableFrom": "chat_members", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_ids": { + "name": "media_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "sticker_ids": { + "name": "sticker_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "reply_message_id": { + "name": "reply_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_from_message_id": { + "name": "forward_from_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sticker_packs": { + "name": "sticker_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stickers": { + "name": "stickers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_flux": { + "name": "user_flux", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "flux": { + "name": "flux", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_flux_user_id_user_id_fk": { + "name": "user_flux_user_id_user_id_fk", + "tableFrom": "user_flux", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_provider_configs": { + "name": "system_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_provider_configs": { + "name": "user_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_provider_configs_owner_id_user_id_fk": { + "name": "user_provider_configs_owner_id_user_id_fk", + "tableFrom": "user_provider_configs", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_checkout_session": { + "name": "stripe_checkout_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_session_id": { + "name": "stripe_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_total": { + "name": "amount_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success_url": { + "name": "success_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_url": { + "name": "cancel_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_checkout_session_user_id_user_id_fk": { + "name": "stripe_checkout_session_user_id_user_id_fk", + "tableFrom": "stripe_checkout_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_checkout_session_stripe_session_id_unique": { + "name": "stripe_checkout_session_stripe_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_customer": { + "name": "stripe_customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_customer_user_id_user_id_fk": { + "name": "stripe_customer_user_id_user_id_fk", + "tableFrom": "stripe_customer", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_customer_stripe_customer_id_unique": { + "name": "stripe_customer_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_invoice": { + "name": "stripe_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_due": { + "name": "amount_due", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_paid": { + "name": "amount_paid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_url": { + "name": "invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_pdf": { + "name": "invoice_pdf", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_invoice_user_id_user_id_fk": { + "name": "stripe_invoice_user_id_user_id_fk", + "tableFrom": "stripe_invoice", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_invoice_stripe_invoice_id_unique": { + "name": "stripe_invoice_stripe_invoice_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_invoice_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_subscription": { + "name": "stripe_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_subscription_user_id_user_id_fk": { + "name": "stripe_subscription_user_id_user_id_fk", + "tableFrom": "stripe_subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_subscription_stripe_subscription_id_unique": { + "name": "stripe_subscription_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_bookmarks": { + "name": "user_character_bookmarks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_bookmarks_user_id_user_id_fk": { + "name": "user_character_bookmarks_user_id_user_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_character_bookmarks_character_id_characters_id_fk": { + "name": "user_character_bookmarks_character_id_characters_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_bookmarks_user_id_character_id_pk": { + "name": "user_character_bookmarks_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_likes": { + "name": "user_character_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_likes_user_id_user_id_fk": { + "name": "user_character_likes_user_id_user_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_character_likes_character_id_characters_id_fk": { + "name": "user_character_likes_character_id_characters_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_likes_user_id_character_id_pk": { + "name": "user_character_likes_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 17bdb9fa1..c1c0f77f5 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1772532802115, "tag": "0001_magenta_skrulls", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1772634952890, + "tag": "0002_mean_tigra", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 94c7d8d51..c0d7c6b89 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -28,6 +28,7 @@ import { createChatService } from './services/chats' import { createConfigKVService } from './services/config-kv' import { createFluxService } from './services/flux' import { createProviderService } from './services/providers' +import { createStripeService } from './services/stripe' import { ApiError, createInternalError } from './utils/error' import { getTrustedOrigin } from './utils/origin' @@ -37,6 +38,7 @@ type ChatService = ReturnType type ProviderService = ReturnType type FluxService = ReturnType type ConfigKVService = ReturnType +type StripeDBService = ReturnType type OtelMetrics = ReturnType @@ -46,11 +48,12 @@ interface AppDeps { chatService: ChatService providerService: ProviderService fluxService: FluxService + stripeService: StripeDBService configKV: ConfigKVService env: Env } -function buildApp({ auth, characterService, chatService, providerService, fluxService, configKV, env }: AppDeps) { +function buildApp({ auth, characterService, chatService, providerService, fluxService, stripeService, configKV, env }: AppDeps) { const logger = useLogger('app').useGlobalConfig() const app = new Hono() @@ -128,7 +131,7 @@ function buildApp({ auth, characterService, chatService, providerService, fluxSe /** * Stripe routes. */ - .route('/api/stripe', createStripeRoutes(fluxService, configKV, env)) + .route('/api/stripe', createStripeRoutes(fluxService, stripeService, configKV, env)) } export type AppType = ReturnType @@ -199,19 +202,25 @@ async function createApp() { build: ({ dependsOn }) => createConfigKVService(dependsOn.redis), }) + const stripeService = injeca.provide('services:stripe', { + dependsOn: { db }, + build: ({ dependsOn }) => createStripeService(dependsOn.db), + }) + const fluxService = injeca.provide('services:flux', { dependsOn: { db, configKV }, build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.configKV), }) await injeca.start() - const resolved = await injeca.resolve({ auth, characterService, chatService, providerService, fluxService, configKV, env: parsedEnv }) + const resolved = await injeca.resolve({ auth, characterService, chatService, providerService, fluxService, stripeService, configKV, env: parsedEnv }) const app = buildApp({ auth: resolved.auth, characterService: resolved.characterService, chatService: resolved.chatService, providerService: resolved.providerService, fluxService: resolved.fluxService, + stripeService: resolved.stripeService, configKV: resolved.configKV, env: resolved.env, }) diff --git a/apps/server/src/routes/stripe.ts b/apps/server/src/routes/stripe.ts index c10fe4b01..a4f2d907c 100644 --- a/apps/server/src/routes/stripe.ts +++ b/apps/server/src/routes/stripe.ts @@ -1,5 +1,7 @@ import type { Env } from '../libs/env' +import type { ConfigKVService } from '../services/config-kv' import type { FluxService } from '../services/flux' +import type { StripeService } from '../services/stripe' import type { HonoEnv } from '../types/hono' import Stripe from 'stripe' @@ -14,7 +16,7 @@ const CheckoutBodySchema = object({ amount: pipe(number(), integer(), minValue(1)), }) -export function createStripeRoutes(fluxService: FluxService, env: Env) { +export function createStripeRoutes(fluxService: FluxService, stripeService: StripeService, configKV: ConfigKVService, env: Env) { const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null return new Hono() @@ -31,6 +33,10 @@ export function createStripeRoutes(fluxService: FluxService, env: Env) { const { amount } = result.output + // Reuse existing stripe customer if available + const customer = await stripeService.getCustomerByUserId(user.id) + const stripeCustomerId = customer?.stripeCustomerId + const session = await stripe.checkout.sessions.create({ payment_method_types: ['card'], line_items: [ @@ -48,14 +54,67 @@ export function createStripeRoutes(fluxService: FluxService, env: Env) { mode: 'payment', success_url: `${env.CLIENT_URL}/settings/flux?success=true`, cancel_url: `${env.CLIENT_URL}/settings/flux?canceled=true`, - customer_email: user.email, + customer: stripeCustomerId, + customer_email: stripeCustomerId ? undefined : user.email, metadata: { userId: user.id, }, }) + // Persist the checkout session + await stripeService.upsertCheckoutSession({ + userId: user.id, + stripeSessionId: session.id, + stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id, + mode: session.mode ?? 'payment', + status: session.status, + paymentStatus: session.payment_status, + amountTotal: session.amount_total, + currency: session.currency, + successUrl: session.success_url, + cancelUrl: session.cancel_url, + stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id, + stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id, + metadata: session.metadata ? JSON.stringify(session.metadata) : null, + expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null, + }) + return c.json({ url: session.url }) }) + + // ---- Orders / checkout sessions history ---- + .get('/orders', authGuard, async (c) => { + const user = c.get('user')! + const sessions = await stripeService.getCheckoutSessionsByUserId(user.id) + return c.json(sessions) + }) + + // ---- Invoices history ---- + .get('/invoices', authGuard, async (c) => { + const user = c.get('user')! + const invoices = await stripeService.getInvoicesByUserId(user.id) + return c.json(invoices) + }) + + // ---- Customer portal ---- + .post('/portal', authGuard, async (c) => { + if (!stripe) + throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED') + + const user = c.get('user')! + const customer = await stripeService.getCustomerByUserId(user.id) + if (!customer) + throw createBadRequestError('No billing account found', 'NO_CUSTOMER') + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: customer.stripeCustomerId, + return_url: `${env.CLIENT_URL}/settings/flux`, + }) + + return c.json({ url: portalSession.url }) + }) + + // ---- Webhook ---- .post('/webhook', async (c) => { if (!stripe || !env.STRIPE_WEBHOOK_SECRET) throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED') @@ -74,21 +133,164 @@ export function createStripeRoutes(fluxService: FluxService, env: Env) { throw createBadRequestError(`Webhook Error: ${message}`, 'WEBHOOK_ERROR') } - if (event.type === 'checkout.session.completed') { - const session = event.data.object - const userId = session.metadata?.userId - const amount = session.amount_total - - if (userId && amount) { - await fluxService.addFlux(userId, amount * env.FLUX_PER_CENT) - - if (session.customer) { - const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id - await fluxService.updateStripeCustomerId(userId, customerId) - } + switch (event.type) { + case 'checkout.session.completed': { + await handleCheckoutSessionCompleted(event.data.object, fluxService, stripeService, configKV) + break + } + case 'customer.created': + case 'customer.updated': { + await handleCustomerEvent(event.data.object, stripeService) + break + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + await handleSubscriptionEvent(event.data.object, stripeService) + break + } + case 'invoice.created': + case 'invoice.updated': + case 'invoice.paid': + case 'invoice.payment_failed': { + await handleInvoiceEvent(event.data.object, fluxService, stripeService, configKV) + break } } return c.json({ received: true }) }) } + +// ---- Webhook handlers ---- + +async function handleCheckoutSessionCompleted( + session: Stripe.Checkout.Session, + fluxService: FluxService, + stripeService: StripeService, + configKV: ConfigKVService, +) { + const userId = session.metadata?.userId + if (!userId) + return + + // Upsert customer record if we got a customer back + if (session.customer) { + const stripeCustomerId = typeof session.customer === 'string' ? session.customer : session.customer.id + await stripeService.upsertCustomer({ + userId, + stripeCustomerId, + email: session.customer_email ?? undefined, + }) + // Keep the legacy field in sync + await fluxService.updateStripeCustomerId(userId, stripeCustomerId) + } + + // Update the checkout session record + await stripeService.upsertCheckoutSession({ + userId, + stripeSessionId: session.id, + stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id, + mode: session.mode ?? 'payment', + status: session.status, + paymentStatus: session.payment_status, + amountTotal: session.amount_total, + currency: session.currency, + successUrl: session.success_url, + cancelUrl: session.cancel_url, + stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id, + stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id, + metadata: session.metadata ? JSON.stringify(session.metadata) : null, + expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null, + }) + + // Add flux for one-time payments + if (session.mode === 'payment' && session.amount_total) { + const fluxPerCent = await configKV.get('FLUX_PER_CENT') + await fluxService.addFlux(userId, session.amount_total * fluxPerCent) + } +} + +async function handleCustomerEvent( + customer: Stripe.Customer | Stripe.DeletedCustomer, + stripeService: StripeService, +) { + if (customer.deleted) + return + + // Try to find existing customer to get userId + const existing = await stripeService.getCustomerByStripeId(customer.id) + if (!existing) + return // We don't know the userId yet; will be linked on checkout + + await stripeService.upsertCustomer({ + userId: existing.userId, + stripeCustomerId: customer.id, + email: customer.email ?? undefined, + name: customer.name ?? undefined, + }) +} + +async function handleSubscriptionEvent( + subscription: Stripe.Subscription, + stripeService: StripeService, +) { + const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id + const customer = await stripeService.getCustomerByStripeId(stripeCustomerId) + if (!customer) + return + + await stripeService.upsertSubscription({ + userId: customer.userId, + stripeSubscriptionId: subscription.id, + stripeCustomerId, + stripePriceId: subscription.items.data[0]?.price?.id, + status: subscription.status, + currentPeriodStart: new Date(subscription.current_period_start * 1000), + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + cancelAtPeriodEnd: String(subscription.cancel_at_period_end), + canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null, + endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : null, + metadata: subscription.metadata ? JSON.stringify(subscription.metadata) : null, + }) +} + +async function handleInvoiceEvent( + invoice: Stripe.Invoice, + fluxService: FluxService, + stripeService: StripeService, + configKV: ConfigKVService, +) { + const stripeCustomerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id + if (!stripeCustomerId) + return + + const customer = await stripeService.getCustomerByStripeId(stripeCustomerId) + if (!customer) + return + + const subscriptionId = typeof invoice.subscription === 'string' ? invoice.subscription : invoice.subscription?.id + + await stripeService.upsertInvoice({ + userId: customer.userId, + stripeInvoiceId: invoice.id, + stripeCustomerId, + stripeSubscriptionId: subscriptionId, + status: invoice.status, + amountDue: invoice.amount_due, + amountPaid: invoice.amount_paid, + currency: invoice.currency, + invoiceUrl: invoice.hosted_invoice_url, + invoicePdf: invoice.invoice_pdf, + periodStart: new Date(invoice.period_start * 1000), + periodEnd: new Date(invoice.period_end * 1000), + paidAt: invoice.status_transitions?.paid_at ? new Date(invoice.status_transitions.paid_at * 1000) : null, + metadata: invoice.metadata ? JSON.stringify(invoice.metadata) : null, + }) + + // Add flux when a subscription invoice is paid + if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId) { + const fluxPerCent = await configKV.get('FLUX_PER_CENT') + await fluxService.addFlux(customer.userId, invoice.amount_paid * fluxPerCent) + } +} diff --git a/apps/server/src/schemas/index.ts b/apps/server/src/schemas/index.ts index 06f004372..9990002e5 100644 --- a/apps/server/src/schemas/index.ts +++ b/apps/server/src/schemas/index.ts @@ -3,4 +3,5 @@ export * from './characters' export * from './chats' export * from './flux' export * from './providers' +export * from './stripe' export * from './user-character' diff --git a/apps/server/src/schemas/stripe.ts b/apps/server/src/schemas/stripe.ts new file mode 100644 index 000000000..0ea4b78aa --- /dev/null +++ b/apps/server/src/schemas/stripe.ts @@ -0,0 +1,124 @@ +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' + +import { relations } from 'drizzle-orm' +import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' + +import { nanoid } from '../utils/id' +import { user } from './accounts' + +/** + * Stripe customers linked to our users. + */ +export const stripeCustomer = pgTable('stripe_customer', { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + stripeCustomerId: text('stripe_customer_id').notNull().unique(), + email: text('email'), + name: text('name'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}) + +/** + * Stripe checkout sessions – every checkout attempt is recorded. + */ +export const stripeCheckoutSession = pgTable('stripe_checkout_session', { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + stripeSessionId: text('stripe_session_id').notNull().unique(), + stripeCustomerId: text('stripe_customer_id'), + mode: text('mode').notNull(), // 'payment' | 'subscription' | 'setup' + status: text('status'), // 'open' | 'complete' | 'expired' + paymentStatus: text('payment_status'), // 'paid' | 'unpaid' | 'no_payment_required' + amountTotal: integer('amount_total'), // in cents + currency: text('currency'), + successUrl: text('success_url'), + cancelUrl: text('cancel_url'), + stripePaymentIntentId: text('stripe_payment_intent_id'), + stripeSubscriptionId: text('stripe_subscription_id'), + metadata: text('metadata'), // JSON stringified + expiresAt: timestamp('expires_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}) + +/** + * Stripe subscriptions. + */ +export const stripeSubscription = pgTable('stripe_subscription', { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + stripeSubscriptionId: text('stripe_subscription_id').notNull().unique(), + stripeCustomerId: text('stripe_customer_id').notNull(), + stripePriceId: text('stripe_price_id'), + status: text('status').notNull(), // 'active' | 'past_due' | 'canceled' | 'incomplete' | etc + currentPeriodStart: timestamp('current_period_start'), + currentPeriodEnd: timestamp('current_period_end'), + cancelAtPeriodEnd: text('cancel_at_period_end'), // 'true' | 'false' + canceledAt: timestamp('canceled_at'), + endedAt: timestamp('ended_at'), + metadata: text('metadata'), // JSON stringified + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}) + +/** + * Stripe invoices – both one-time and subscription invoices. + */ +export const stripeInvoice = pgTable('stripe_invoice', { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + stripeInvoiceId: text('stripe_invoice_id').notNull().unique(), + stripeCustomerId: text('stripe_customer_id'), + stripeSubscriptionId: text('stripe_subscription_id'), + status: text('status'), // 'draft' | 'open' | 'paid' | 'uncollectible' | 'void' + amountDue: integer('amount_due'), // in cents + amountPaid: integer('amount_paid'), // in cents + currency: text('currency'), + invoiceUrl: text('invoice_url'), + invoicePdf: text('invoice_pdf'), + periodStart: timestamp('period_start'), + periodEnd: timestamp('period_end'), + paidAt: timestamp('paid_at'), + metadata: text('metadata'), // JSON stringified + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}) + +// ---------- Relations ---------- + +export const stripeCustomerRelations = relations(stripeCustomer, ({ one, many }) => ({ + user: one(user, { fields: [stripeCustomer.userId], references: [user.id] }), + checkoutSessions: many(stripeCheckoutSession), + subscriptions: many(stripeSubscription), + invoices: many(stripeInvoice), +})) + +export const stripeCheckoutSessionRelations = relations(stripeCheckoutSession, ({ one }) => ({ + user: one(user, { fields: [stripeCheckoutSession.userId], references: [user.id] }), + customer: one(stripeCustomer, { fields: [stripeCheckoutSession.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }), +})) + +export const stripeSubscriptionRelations = relations(stripeSubscription, ({ one }) => ({ + user: one(user, { fields: [stripeSubscription.userId], references: [user.id] }), + customer: one(stripeCustomer, { fields: [stripeSubscription.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }), +})) + +export const stripeInvoiceRelations = relations(stripeInvoice, ({ one }) => ({ + user: one(user, { fields: [stripeInvoice.userId], references: [user.id] }), + customer: one(stripeCustomer, { fields: [stripeInvoice.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }), +})) + +// ---------- Types ---------- + +export type StripeCustomer = InferSelectModel +export type NewStripeCustomer = InferInsertModel + +export type StripeCheckoutSession = InferSelectModel +export type NewStripeCheckoutSession = InferInsertModel + +export type StripeSubscription = InferSelectModel +export type NewStripeSubscription = InferInsertModel + +export type StripeInvoice = InferSelectModel +export type NewStripeInvoice = InferInsertModel diff --git a/apps/server/src/services/stripe.ts b/apps/server/src/services/stripe.ts new file mode 100644 index 000000000..f0e9baffd --- /dev/null +++ b/apps/server/src/services/stripe.ts @@ -0,0 +1,129 @@ +import type { Database } from '../libs/db' +import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../schemas/stripe' + +import { eq } from 'drizzle-orm' + +import * as schema from '../schemas/stripe' + +export function createStripeService(db: Database) { + return { + // ---- Customer ---- + + async upsertCustomer(data: NewStripeCustomer) { + const existing = await db.query.stripeCustomer.findFirst({ + where: eq(schema.stripeCustomer.stripeCustomerId, data.stripeCustomerId), + }) + + if (existing) { + const [updated] = await db.update(schema.stripeCustomer) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.stripeCustomer.stripeCustomerId, data.stripeCustomerId)) + .returning() + return updated + } + + const [created] = await db.insert(schema.stripeCustomer) + .values(data) + .returning() + return created + }, + + async getCustomerByUserId(userId: string) { + return db.query.stripeCustomer.findFirst({ + where: eq(schema.stripeCustomer.userId, userId), + }) + }, + + async getCustomerByStripeId(stripeCustomerId: string) { + return db.query.stripeCustomer.findFirst({ + where: eq(schema.stripeCustomer.stripeCustomerId, stripeCustomerId), + }) + }, + + // ---- Checkout Session ---- + + async upsertCheckoutSession(data: NewStripeCheckoutSession) { + const existing = await db.query.stripeCheckoutSession.findFirst({ + where: eq(schema.stripeCheckoutSession.stripeSessionId, data.stripeSessionId), + }) + + if (existing) { + const [updated] = await db.update(schema.stripeCheckoutSession) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.stripeCheckoutSession.stripeSessionId, data.stripeSessionId)) + .returning() + return updated + } + + const [created] = await db.insert(schema.stripeCheckoutSession) + .values(data) + .returning() + return created + }, + + async getCheckoutSessionsByUserId(userId: string) { + return db.query.stripeCheckoutSession.findMany({ + where: eq(schema.stripeCheckoutSession.userId, userId), + orderBy: (t, { desc }) => [desc(t.createdAt)], + }) + }, + + // ---- Subscription ---- + + async upsertSubscription(data: NewStripeSubscription) { + const existing = await db.query.stripeSubscription.findFirst({ + where: eq(schema.stripeSubscription.stripeSubscriptionId, data.stripeSubscriptionId), + }) + + if (existing) { + const [updated] = await db.update(schema.stripeSubscription) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.stripeSubscription.stripeSubscriptionId, data.stripeSubscriptionId)) + .returning() + return updated + } + + const [created] = await db.insert(schema.stripeSubscription) + .values(data) + .returning() + return created + }, + + async getActiveSubscription(userId: string) { + return db.query.stripeSubscription.findFirst({ + where: eq(schema.stripeSubscription.userId, userId), + orderBy: (t, { desc }) => [desc(t.createdAt)], + }) + }, + + // ---- Invoice ---- + + async upsertInvoice(data: NewStripeInvoice) { + const existing = await db.query.stripeInvoice.findFirst({ + where: eq(schema.stripeInvoice.stripeInvoiceId, data.stripeInvoiceId), + }) + + if (existing) { + const [updated] = await db.update(schema.stripeInvoice) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.stripeInvoice.stripeInvoiceId, data.stripeInvoiceId)) + .returning() + return updated + } + + const [created] = await db.insert(schema.stripeInvoice) + .values(data) + .returning() + return created + }, + + async getInvoicesByUserId(userId: string) { + return db.query.stripeInvoice.findMany({ + where: eq(schema.stripeInvoice.userId, userId), + orderBy: (t, { desc }) => [desc(t.createdAt)], + }) + }, + } +} + +export type StripeService = ReturnType