diff --git a/api/.env.development b/api/.env.development index 9d6b926..b9fbc76 100644 --- a/api/.env.development +++ b/api/.env.development @@ -4,6 +4,8 @@ SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhY STREAM_CHAT_API_KEY=t5vvvddteapa STREAM_CHAT_API_SECRET=zrr32sqenw3atpv9rnz2nhhyyncf7bunr7fmfqy9r7e69fcw978dhzevmhpxa2jj +STRIPE_SECRET_KEY=sk_test_51Qc159AmcXPHW4mTeEs86NXY2lAz6pPKiSteECBTsQ2BmaJxeFkbO4uopoMZM8USggRYJjuwJ4GCXVzy6ROT1hMJ00NJGOUM33 +STRIPE_WEBHOOK_SECRET=whsec_4c6f3742c4f3760eff1ef974202cb7f27acc93b8a0da6529db7b2ff2d5acec02 XTABLO_URL="https://app-staging.xtablo.com" diff --git a/api/package-lock.json b/api/package-lock.json index 8e788fd..f4f028c 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@hono/node-server": "^1.14.4", + "@supabase/stripe-sync-engine": "^0.45.0", "@supabase/supabase-js": "^2.49.4", "cors": "^2.8.5", "dd-trace": "^5.74.0", @@ -20,6 +21,7 @@ "multer": "^2.0.2", "nodemailer": "^7.0.4", "stream-chat": "^9.8.0", + "stripe": "^19.2.0", "ts-node": "^10.9.2" }, "devDependencies": { @@ -33,6 +35,7 @@ "@types/sinon": "^17.0.0", "chai": "^4.3.0", "mocha": "^10.0.0", + "pino": "^10.1.0", "sinon": "^17.0.0", "tsx": "^4.7.1", "typescript": "^5.8.3" @@ -2610,6 +2613,12 @@ "node": ">=14" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "dev": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3452,6 +3461,19 @@ "@supabase/node-fetch": "^2.6.14" } }, + "node_modules/@supabase/stripe-sync-engine": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@supabase/stripe-sync-engine/-/stripe-sync-engine-0.45.0.tgz", + "integrity": "sha512-3yP6Lyqg+jBZdI3MyGM0gBGmVmaaSBI5QO4hwvmsdJmIztU/3Wvu/YF0S8Ga1waWdeG1/9YChvVJV7gRON+t1A==", + "dependencies": { + "pg": "^8.16.3", + "pg-node-migrations": "0.0.8", + "yesql": "^7.0.0" + }, + "peerDependencies": { + "stripe": "> 11" + } + }, "node_modules/@supabase/supabase-js": { "version": "2.50.0", "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.50.0.tgz", @@ -3844,6 +3866,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/axios": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", @@ -5543,9 +5574,9 @@ } }, "node_modules/hono": { - "version": "4.9.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.9.8.tgz", - "integrity": "sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg==", + "version": "4.10.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.4.tgz", + "integrity": "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg==", "engines": { "node": ">=16.9.0" } @@ -6621,6 +6652,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6897,6 +6937,22 @@ "node": ">=4.0.0" } }, + "node_modules/pg-node-migrations": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/pg-node-migrations/-/pg-node-migrations-0.0.8.tgz", + "integrity": "sha512-44cMl9umOmCv0hzZyEcvjEq8Bm8u7mrzggZ06qXTJVSsMMB4j2OsjG+rSp+uzeKWyP2Vu0K9Ye2wKtjFUJwrdw==", + "license": "MIT", + "dependencies": { + "pg": "^8.6.0", + "sql-template-strings": "^2.2.2" + }, + "bin": { + "pg-validate-migrations": "dist/bin/validate.js" + }, + "engines": { + "node": ">10.17.0" + } + }, "node_modules/pg-pool": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", @@ -6950,6 +7006,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz", + "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==", + "dev": true, + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dev": true, + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "dev": true + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -7022,6 +7115,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/proto3-json-serializer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", @@ -7131,6 +7240,12 @@ "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "dev": true + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -7195,6 +7310,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "dev": true, + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7299,6 +7423,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7523,6 +7656,15 @@ "node": ">= 14" } }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "dev": true, + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -7544,6 +7686,15 @@ "node": ">= 10.x" } }, + "node_modules/sql-template-strings": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/sql-template-strings/-/sql-template-strings-2.2.2.tgz", + "integrity": "sha512-UXhXR2869FQaD+GMly8jAMCRZ94nU5KcrFetZfWEMd+LVVG6y0ExgHAhatEcKZ/wk8YcKPdi+hiD2wm75lq3/Q==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/stream-chat": { "version": "9.8.0", "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-9.8.0.tgz", @@ -7659,6 +7810,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-19.2.0.tgz", + "integrity": "sha512-strzN8luMGMC1LEleGKg7pJGXFx0kSS4y/uSjK8yPQV9SUBMtJVAp/v8XMQLRnMbXaSaWLrIaHcMlKcsizdRDQ==", + "dependencies": { + "qs": "^6.11.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strnum": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", @@ -7791,6 +7961,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "dev": true, + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -8186,6 +8365,12 @@ "node": ">=10" } }, + "node_modules/yesql": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/yesql/-/yesql-7.0.0.tgz", + "integrity": "sha512-sosfr7agy4ibLM7BvXBkM6BpBmKMGuBO8DUYQEuey+QqaqrgW+2bsSg6D050ocBYIz0PuHxUyehyzEztZTU4pw==", + "license": "ISC" + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/api/package.json b/api/package.json index b4bcf26..1bf8aee 100644 --- a/api/package.json +++ b/api/package.json @@ -14,6 +14,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@hono/node-server": "^1.14.4", + "@supabase/stripe-sync-engine": "^0.45.0", "@supabase/supabase-js": "^2.49.4", "cors": "^2.8.5", "dd-trace": "^5.74.0", @@ -26,6 +27,7 @@ "multer": "^2.0.2", "nodemailer": "^7.0.4", "stream-chat": "^9.8.0", + "stripe": "^19.2.0", "ts-node": "^10.9.2" }, "devDependencies": { @@ -39,6 +41,7 @@ "@types/sinon": "^17.0.0", "chai": "^4.3.0", "mocha": "^10.0.0", + "pino": "^10.1.0", "sinon": "^17.0.0", "tsx": "^4.7.1", "typescript": "^5.8.3" diff --git a/api/src/config.ts b/api/src/config.ts index b2e4b28..e50a3e6 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -6,8 +6,11 @@ export interface AppConfig { SUPABASE_URL: string; SUPABASE_SERVICE_ROLE_KEY: string; SUPABASE_CONNECTION_STRING: string; + SUPABASE_CA_CERT: string; STREAM_CHAT_API_KEY: string; STREAM_CHAT_API_SECRET: string; + STRIPE_SECRET_KEY: string; + STRIPE_WEBHOOK_SECRET: string; EMAIL_USER: string; EMAIL_CLIENT_ID: string; EMAIL_CLIENT_SECRET: string; @@ -47,11 +50,17 @@ function createConfig(): AppConfig { process.env.SUPABASE_SERVICE_ROLE_KEY ), SUPABASE_CONNECTION_STRING: process.env.SUPABASE_CONNECTION_STRING || "", + SUPABASE_CA_CERT: process.env.SUPABASE_CA_CERT || "", STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY), STREAM_CHAT_API_SECRET: validateEnvVar( "STREAM_CHAT_API_SECRET", process.env.STREAM_CHAT_API_SECRET ), + STRIPE_SECRET_KEY: validateEnvVar("STRIPE_SECRET_KEY", process.env.STRIPE_SECRET_KEY), + STRIPE_WEBHOOK_SECRET: validateEnvVar( + "STRIPE_WEBHOOK_SECRET", + process.env.STRIPE_WEBHOOK_SECRET + ), EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER), EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID), EMAIL_CLIENT_SECRET: validateEnvVar("EMAIL_CLIENT_SECRET", process.env.EMAIL_CLIENT_SECRET), diff --git a/api/src/database.types.ts b/api/src/database.types.ts index 56ea694..2071436 100644 --- a/api/src/database.types.ts +++ b/api/src/database.types.ts @@ -355,6 +355,7 @@ export type Database = { last_name: string | null last_signed_in: string | null name: string | null + plan: Database["public"]["Enums"]["subscription_plan"] | null short_user_id: string } Insert: { @@ -366,6 +367,7 @@ export type Database = { last_name?: string | null last_signed_in?: string | null name?: string | null + plan?: Database["public"]["Enums"]["subscription_plan"] | null short_user_id: string } Update: { @@ -377,6 +379,7 @@ export type Database = { last_name?: string | null last_signed_in?: string | null name?: string | null + plan?: Database["public"]["Enums"]["subscription_plan"] | null short_user_id?: string } Relationships: [] @@ -628,9 +631,100 @@ export type Database = { } Functions: { generate_random_string: { Args: { length?: number }; Returns: string } + get_my_active_subscription: { + Args: never + Returns: { + billing_interval: string + cancel_at_period_end: boolean + currency: string + current_period_end: string + current_period_start: string + first_name: string + last_name: string + plan: Database["public"]["Enums"]["subscription_plan"] + product_name: string + status: string + subscription_id: string + unit_amount: number + user_email: string + user_id: string + }[] + } + get_stripe_prices: { + Args: never + Returns: { + active: boolean + created: number + currency: string + id: string + metadata: Json + product: string + recurring: Json + unit_amount: number + }[] + } + get_stripe_products: { + Args: never + Returns: { + active: boolean + created: number + description: string + id: string + metadata: Json + name: string + }[] + } + get_user_stripe_customer: { + Args: never + Returns: { + created: number + email: string + id: string + metadata: Json + user_id: string + }[] + } + get_user_stripe_customer_id: { + Args: { user_uuid: string } + Returns: string + } + get_user_stripe_subscriptions: { + Args: never + Returns: { + cancel_at_period_end: boolean + canceled_at: number + created: number + current_period_end: number + current_period_start: number + customer: string + id: string + metadata: Json + price_id: string + quantity: number + status: string + trial_end: Json + trial_start: Json + user_id: string + }[] + } + get_user_subscription_status: { + Args: { user_uuid: string } + Returns: { + cancel_at_period_end: boolean + current_period_end: number + current_period_start: number + plan: Database["public"]["Enums"]["subscription_plan"] + price_id: string + product_name: string + status: string + subscription_id: string + }[] + } + is_paying_user: { Args: { user_uuid: string }; Returns: boolean } } Enums: { devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired" + subscription_plan: "none" | "trial" | "standard" } CompositeTypes: { time_range: { @@ -762,6 +856,7 @@ export const Constants = { public: { Enums: { devis_status: ["draft", "sent", "accepted", "rejected", "expired"], + subscription_plan: ["none", "trial", "standard"], }, }, } as const diff --git a/api/src/routers.ts b/api/src/routers.ts index 401f3b8..46b2122 100644 --- a/api/src/routers.ts +++ b/api/src/routers.ts @@ -5,6 +5,7 @@ import { tabloDataRouter } from "./tablo_data.js"; import { taskRouter } from "./tasks.js"; import { userRouter } from "./user.js"; import { notesRouter } from "./notes.js"; +import { stripeRouter, stripeWebhookRouter } from "./stripe.js"; export const mainRouter = new Hono<{ Bindings: { @@ -36,3 +37,6 @@ mainRouter.route("/tablos", tabloRouter); mainRouter.route("/tasks", taskRouter); mainRouter.route("/tablo-data", tabloDataRouter); mainRouter.route("/notes", notesRouter); +// stripe routes +mainRouter.route("/stripe", stripeRouter); +mainRouter.route("/stripe-webhook", stripeWebhookRouter); diff --git a/api/src/stripe.ts b/api/src/stripe.ts new file mode 100644 index 0000000..4edf93c --- /dev/null +++ b/api/src/stripe.ts @@ -0,0 +1,279 @@ +import { Hono } from "hono"; +import type { SupabaseClient, User } from "@supabase/supabase-js"; +import Stripe from "stripe"; +import { authMiddleware, regularUserCheckMiddleware } from "./middleware.js"; +import { config } from "./config.js"; +import { stripeSync } from "./stripeSync.js"; + +const stripe = new Stripe(config.STRIPE_SECRET_KEY || "", { + apiVersion: "2025-10-29.clover", +}); + +export const stripeRouter = new Hono<{ + Variables: { + user: User; + supabase: SupabaseClient; + }; +}>(); + +stripeRouter.use(authMiddleware); + +// ============================================================================ +// Webhook endpoint (no auth required - validated by signature) +// ============================================================================ + +export const stripeWebhookRouter = new Hono(); + +/** + * Stripe webhook handler using @supabase/stripe-sync-engine + * This automatically syncs all Stripe events to Supabase tables + * Repository: https://github.com/supabase/stripe-sync-engine + */ +stripeWebhookRouter.post("/", async (c) => { + try { + const signature = c.req.header("stripe-signature"); + + if (!signature) { + return c.json({ error: "No signature provided" }, 400); + } + + // Get raw body for signature verification + const rawBody = await c.req.text(); + + // Process webhook using Stripe Sync Engine + // This handles signature verification and syncing automatically + await stripeSync.processWebhook(rawBody, signature); + + return c.json({ received: true }); + } catch (error) { + console.error("Webhook error:", error); + return c.json( + { error: error instanceof Error ? error.message : "Webhook processing failed" }, + 400 + ); + } +}); + +// ============================================================================ +// Authenticated endpoints +// ============================================================================ + +/** + * Create a Stripe Checkout Session + * POST /api/v1/stripe/create-checkout-session + */ +stripeRouter.post("/create-checkout-session", regularUserCheckMiddleware, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const body = await c.req.json(); + const { priceId, successUrl, cancelUrl } = body; + + if (!priceId) { + return c.json({ error: "priceId is required" }, 400); + } + + try { + // Get or create Stripe customer + let customerId: string; + + // Check if customer already exists by querying stripe schema with metadata filter + // Note: Using service role, so we filter manually by metadata + const { data: customers } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata") + .limit(1000); // Get all customers to filter by metadata + + const existingCustomer = customers?.find( + (c: Stripe.Customer) => c.metadata?.user_id === user.id + ); + + if (existingCustomer) { + customerId = existingCustomer.id; + } else { + // Create new Stripe customer with user_id in metadata + // stripe-sync-engine will automatically sync this to the database via webhook + const customer = await stripe.customers.create({ + email: user.email!, + metadata: { + user_id: user.id, // Stored in metadata for tracking + }, + }); + + customerId = customer.id; + } + + // Create Checkout Session + const session = await stripe.checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: "subscription", + success_url: successUrl || `${process.env.FRONTEND_URL}/settings?success=true`, + cancel_url: cancelUrl || `${process.env.FRONTEND_URL}/settings?canceled=true`, + metadata: { + user_id: user.id, + }, + subscription_data: { + metadata: { + user_id: user.id, + }, + }, + }); + + return c.json({ sessionId: session.id, url: session.url }); + } catch (error) { + console.error("Error creating checkout session:", error); + return c.json( + { error: error instanceof Error ? error.message : "Failed to create checkout session" }, + 500 + ); + } +}); + +/** + * Create a Stripe Customer Portal Session + * POST /api/v1/stripe/create-portal-session + */ +stripeRouter.post("/create-portal-session", regularUserCheckMiddleware, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const body = await c.req.json(); + const { returnUrl } = body; + + try { + // Get Stripe customer ID by filtering metadata + const { data: customers } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata"); + + const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); + + if (!customer) { + return c.json({ error: "No Stripe customer found" }, 404); + } + + // Create portal session + const session = await stripe.billingPortal.sessions.create({ + customer: customer.id, + return_url: returnUrl || `${process.env.FRONTEND_URL}/settings`, + }); + + return c.json({ url: session.url }); + } catch (error) { + console.error("Error creating portal session:", error); + return c.json( + { error: error instanceof Error ? error.message : "Failed to create portal session" }, + 500 + ); + } +}); + +// Note: Subscription status queries are handled directly from the frontend +// using Supabase client with RLS policies. No API endpoints needed for reads. + +/** + * Cancel subscription at period end + * POST /api/v1/stripe/cancel-subscription + */ +stripeRouter.post("/cancel-subscription", regularUserCheckMiddleware, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + + try { + // Get user's Stripe customer first + const { data: customers } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata"); + + const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); + + if (!customer) { + return c.json({ error: "Customer not found" }, 404); + } + + // Get user's active subscription for this customer + const { data: subscription } = await supabase + .schema("stripe") + .from("subscriptions") + .select("id, status") + .eq("customer", customer.id) + .in("status", ["active", "trialing"]) + .maybeSingle(); + + if (!subscription) { + return c.json({ error: "No active subscription found" }, 404); + } + + // Cancel subscription at period end in Stripe + // The webhook will automatically sync the change to our database + await stripe.subscriptions.cancel(subscription.id); + + return c.json({ + success: true, + message: "Subscription will cancel at period end", + }); + } catch (error) { + console.error("Error canceling subscription:", error); + return c.json( + { error: error instanceof Error ? error.message : "Failed to cancel subscription" }, + 500 + ); + } +}); + +/** + * Reactivate a canceled subscription + * POST /api/v1/stripe/reactivate-subscription + */ +stripeRouter.post("/reactivate-subscription", regularUserCheckMiddleware, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + + try { + // Get user's Stripe customer first + const { data: customers } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata"); + + const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); + + if (!customer) { + return c.json({ error: "No subscription found to reactivate" }, 404); + } + + // Get user's subscription that's set to cancel + const { data: subscription } = await supabase + .schema("stripe") + .from("subscriptions") + .select("id, cancel_at_period_end") + .eq("customer", customer.id) + .eq("cancel_at_period_end", true) + .maybeSingle(); + + if (!subscription) { + return c.json({ error: "No subscription found to reactivate" }, 404); + } + + // Reactivate subscription in Stripe + // The webhook will automatically sync the change to our database + await stripe.subscriptions.update(subscription.id, { + cancel_at_period_end: false, + }); + + return c.json({ success: true, message: "Subscription reactivated" }); + } catch (error) { + console.error("Error reactivating subscription:", error); + return c.json( + { error: error instanceof Error ? error.message : "Failed to reactivate subscription" }, + 500 + ); + } +}); diff --git a/api/src/stripeSync.ts b/api/src/stripeSync.ts new file mode 100644 index 0000000..ee7d429 --- /dev/null +++ b/api/src/stripeSync.ts @@ -0,0 +1,19 @@ +import { StripeSync } from "@supabase/stripe-sync-engine"; +import { config } from "./config.js"; + +const ssl = { + ca: Buffer.from(config.SUPABASE_CA_CERT, "base64").toString("utf-8"), +}; + +export const stripeSync = new StripeSync({ + stripeSecretKey: config.STRIPE_SECRET_KEY || "", + stripeWebhookSecret: config.STRIPE_WEBHOOK_SECRET || "", + schema: "stripe", // Use stripe schema (library default) + poolConfig: { + connectionString: config.SUPABASE_CONNECTION_STRING || "", // Direct Postgres connection string + ssl, + max: 10, + }, + // Optional: force refetch from Stripe API to avoid stale data + revalidateObjectsViaStripeApi: ["subscription", "customer"], +}); diff --git a/api/src/tasks.ts b/api/src/tasks.ts index 3f5e0d3..e8ace72 100644 --- a/api/src/tasks.ts +++ b/api/src/tasks.ts @@ -5,6 +5,7 @@ import { config } from "./config.js"; import { writeCalendarFileToR2 } from "./helpers.js"; import { streamChatMiddleware } from "./middleware.js"; import type { StreamChat } from "stream-chat"; +import { stripeSync } from "./stripeSync.js"; export const taskRouter = new Hono<{ Variables: { supabase: SupabaseClient }; @@ -92,3 +93,13 @@ taskRouter.post( return c.json({ message: `Synced ${tablosData.length} tablo names` }); } ); + +taskRouter.post("/sync-stripe-subscriptions", async (c) => { + if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) { + return c.json({ error: "Unauthorized" }, 401); + } + + const data = await stripeSync.syncBackfill({ object: "all" }); + + return c.json({ message: `Synced ${data.subscriptions?.synced} stripe subscriptions` }); +});