From dbec17cb5107010dccf723e16198744d00c39147 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:17:45 +0000 Subject: [PATCH 1/6] Remove ads and paid billing (Stripe/Polar premium) Ads: - Remove Publift/Fuse ad slots, the Primis video ad, sticky footer handling, and the fuse.js script tag - Delete PubliftAd, CarbonAd, and the old tomotime Advertisement component and assets, plus commented-out AdSense code - Restore the pre-ads event page layout and drop publift breakpoints Billing: - Remove the upgrade dialog, free-event paywall, Premium badge, "Upgrade" links, billing settings section, and upgrade sign-in redirects - Delete StripeRedirect and the Test page that only showed the dialog - Remove Stripe and Polar routes/services, the is-premium endpoint, upgrade analytics endpoints, premium fields on the User model, and the monetization Slack channel - Drop stripe-go and canvas-confetti, regenerate Swagger docs, and remove payment env vars from the template and DEPLOYMENT.md Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EcVVmFPmgnmC1skyWANoyM --- CLAUDE.md | 6 +- DEPLOYMENT.md | 21 - frontend/package-lock.json | 11 - frontend/package.json | 1 - frontend/public/index.html | 1 - frontend/src/App.vue | 54 +- frontend/src/assets/ads/tomotime.png | Bin 594567 -> 0 bytes frontend/src/assets/ads/tomotime_mobile.png | Bin 172575 -> 0 bytes frontend/src/components/EventType.vue | 35 - .../src/components/event/Advertisement.vue | 86 -- frontend/src/components/event/CarbonAd.vue | 70 -- frontend/src/components/event/PubliftAd.vue | 63 -- frontend/src/components/home/Dashboard.vue | 42 +- .../pricing/AlreadyDonatedDialog.vue | 31 - .../components/pricing/StudentProofDialog.vue | 32 - .../src/components/pricing/UpgradeDialog.vue | 764 ------------------ .../schedule_overlap/ScheduleOverlap.vue | 46 +- .../components/schedule_overlap/ToolRow.vue | 40 +- frontend/src/constants.js | 10 - frontend/src/router/index.js | 17 - frontend/src/store/index.js | 59 +- frontend/src/utils/general_utils.js | 13 - frontend/src/views/Auth.vue | 20 +- frontend/src/views/Event.vue | 149 +--- frontend/src/views/Settings.vue | 36 +- frontend/src/views/SignIn.vue | 34 +- frontend/src/views/StripeRedirect.vue | 135 ---- frontend/src/views/Test.vue | 24 - frontend/tailwind.config.js | 4 - server/.env.template | 20 - server/db/users.go | 38 - server/docs/docs.go | 351 +------- server/docs/swagger.json | 348 +------- server/docs/swagger.yaml | 219 +---- server/go.mod | 3 - server/go.sum | 14 - server/main.go | 11 - server/models/user.go | 20 +- server/routes/analytics.go | 90 --- server/routes/events.go | 4 +- server/routes/polar.go | 465 ----------- server/routes/stripe.go | 239 ------ server/routes/users.go | 21 +- server/services/polar/polar.go | 262 ------ server/services/polar/polar_test.go | 60 -- server/slackbot/slackbot.go | 6 +- 46 files changed, 49 insertions(+), 3926 deletions(-) delete mode 100644 frontend/src/assets/ads/tomotime.png delete mode 100644 frontend/src/assets/ads/tomotime_mobile.png delete mode 100644 frontend/src/components/event/Advertisement.vue delete mode 100644 frontend/src/components/event/CarbonAd.vue delete mode 100644 frontend/src/components/event/PubliftAd.vue delete mode 100644 frontend/src/components/pricing/AlreadyDonatedDialog.vue delete mode 100644 frontend/src/components/pricing/StudentProofDialog.vue delete mode 100644 frontend/src/components/pricing/UpgradeDialog.vue delete mode 100644 frontend/src/views/StripeRedirect.vue delete mode 100644 frontend/src/views/Test.vue delete mode 100644 server/routes/polar.go delete mode 100644 server/routes/stripe.go delete mode 100644 server/services/polar/polar.go delete mode 100644 server/services/polar/polar_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 65e1f3eb9..79cf8deaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Internal identifiers (Go module `schej.it/server`, Mongo DB `schej-it`, prod ema - MongoDB backup/restore: `mongodump --host=localhost:27017 --db=schej-it` / `mongorestore --uri mongodb://localhost:27017 ./dump --drop`. ### Required env vars for local server boot -`SESSION_SECRET` (≥32 chars) is enforced at startup. `CLIENT_ID`/`CLIENT_SECRET` (Google OAuth) and `ENCRYPTION_KEY` are required for most flows. See `server/.env.template` and `DEPLOYMENT.md` for the full list (Stripe, Microsoft, Listmonk, Slack, Discord, Gmail, etc.). +`SESSION_SECRET` (≥32 chars) is enforced at startup. `CLIENT_ID`/`CLIENT_SECRET` (Google OAuth) and `ENCRYPTION_KEY` are required for most flows. See `server/.env.template` and `DEPLOYMENT.md` for the full list (Microsoft, Listmonk, Slack, Discord, Gmail, etc.). For local frontend → local backend, set `CORS_ORIGINS=http://localhost:8080` in `server/.env`. @@ -40,7 +40,7 @@ For local frontend → local backend, set `CORS_ORIGINS=http://localhost:8080` i ### Backend (Gin + MongoDB) `server/main.go` wires everything: CORS, cookie sessions, Mongo init (`db.Init`), Google Cloud Tasks init (`services/gcloud.InitTasks`), then mounts API groups under `/api` via `routes.Init*` and `slackbot.InitSlackbot`. After API routes, it walks `frontend/dist` and registers each file as a static route, loads `index.html` as a template, and falls back to a `NoRoute` handler that injects per-route OG meta tags (e.g. for `/e/:eventId` it looks up the event to set the title and OG image). -- `routes/` — HTTP handlers grouped by domain: `auth.go`, `user.go`, `users.go`, `events.go`, `folders.go`, `analytics.go`, `stripe.go`. Route comments use Swag annotations; `swag init` regenerates `docs/`. +- `routes/` — HTTP handlers grouped by domain: `auth.go`, `user.go`, `users.go`, `events.go`, `folders.go`, `analytics.go`. Route comments use Swag annotations; `swag init` regenerates `docs/`. - `models/` — Mongo document structs (`Event`, `User`, `Response`, `Folder`, `Attendee`, `Calendar`, `Set`, `Otp`, `FriendRequest`, `Location`, `DailyUserLog`). - `db/` — Mongo accessors per model (`events.go`, `users.go`, `folders.go`, `analytics.go`, `utils.go`) plus `init.go`. Treat this as the only layer that talks to Mongo. - `services/` — external integrations. Notable: `calendar/` (Google, Outlook/Graph, Apple CalDAV via `jonyTF/go-webdav`, generic ICS), `auth/`, `contacts/`, `gcloud/` (Cloud Tasks for scheduled jobs), `listmonk/`, `microsoftgraph/`. @@ -51,7 +51,7 @@ For local frontend → local backend, set `CORS_ORIGINS=http://localhost:8080` i - `logger/` — wraps log file (`logs.log`) + stdout via `gin.DefaultWriter`. ### Frontend (Vue 2 SPA) -- `src/router/index.js` — routes (`Landing`, `Home`, `Event`, `Group`, `Friends`, `Settings`, `SignIn`/`SignUp`/`Auth`, `StripeRedirect`, etc. — see `src/views/`). +- `src/router/index.js` — routes (`Landing`, `Home`, `Event`, `Group`, `Friends`, `Settings`, `SignIn`/`SignUp`/`Auth`, etc. — see `src/views/`). - `src/store/index.js` — single Vuex store (auth user, events, snackbar, dialogs). - `src/components/` — organized by feature folder (`event/`, `groups/`, `home/`, `landing/`, `pricing/`, `settings/`, `schedule_overlap/`, `calendar_permission_dialogs/`, `sign_up_form/`, `general/`) plus top-level shared components. - `src/utils/` — date math (`date_utils.js`, uses `dayjs`/`moment`/`spacetime`), `fetch_utils.js` (API client), `plugin_utils.js` (handles the postMessage plugin API — see `PLUGIN_API_README.md`), `sign_in_utils.js`, `location_utils.js`, `services/` (calendar-provider abstractions on the client side). diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 145521cfe..b6d3e5ae4 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -106,27 +106,6 @@ Create `server/.env` from the template (`server/.env.template`). | `ENCRYPTION_KEY` | Key for encrypting sensitive data (generate with `openssl rand -base64 32`) | | `SESSION_SECRET` | Session cookie encryption key (generate with `openssl rand -base64 32`) | -#### Optional — Payments - -New purchases go through [Polar](https://polar.sh). Stripe is only kept for subscribers who signed up before the move, whose subscriptions still renew on Stripe. - -| Variable | Description | -| ----------------------- | ------------------------------------------------------------------ | -| `POLAR_ACCESS_TOKEN` | Polar Organization Access Token | -| `POLAR_WEBHOOK_SECRET` | Polar webhook endpoint secret | -| `POLAR_SERVER` | `sandbox` or `production` (default: `production`) | -| `POLAR_*_PRODUCT_ID` | Polar product IDs for each plan (monthly, yearly, and student variants; lifetime is disabled) | -| `STRIPE_API_KEY` | Stripe API key (legacy subscribers only) | -| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (legacy subscribers only) | - -Polar setup: - -1. Create one product per plan. Each Polar product has a single pricing model, so monthly and yearly are separate products. -2. Create an Organization Access Token with the `checkouts:write`, `products:read`, and `customer_sessions:write` scopes. -3. Add a webhook endpoint at `https:///api/polar/webhook` with format **Raw** and API version **2026-10**, subscribed to `order.paid`, `subscription.active`, `subscription.past_due`, `subscription.revoked`, and `subscription.migrated`. - -For local development, `polar listen http://localhost:3002/api/polar/webhook` forwards webhooks and prints the secret to use for `POLAR_WEBHOOK_SECRET`. - #### Optional — Additional Calendars | Variable | Description | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 32608a4ad..1e1dbe5ed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,6 @@ "@gtm-support/vue2-gtm": "^2.0.0", "@rive-app/canvas": "^2.10.0", "bson-objectid": "^2.0.4", - "canvas-confetti": "^1.9.3", "color": "^4.2.3", "copy-image-clipboard": "^2.1.2", "core-js": "^3.35.1", @@ -4940,16 +4939,6 @@ } ] }, - "node_modules/canvas-confetti": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.3.tgz", - "integrity": "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==", - "license": "ISC", - "funding": { - "type": "donate", - "url": "https://www.paypal.me/kirilvatev" - } - }, "node_modules/case-sensitive-paths-webpack-plugin": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a21d12254..c2b83a94d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,6 @@ "@gtm-support/vue2-gtm": "^2.0.0", "@rive-app/canvas": "^2.10.0", "bson-objectid": "^2.0.4", - "canvas-confetti": "^1.9.3", "color": "^4.2.3", "copy-image-clipboard": "^2.1.2", "core-js": "^3.35.1", diff --git a/frontend/public/index.html b/frontend/public/index.html index a300a8a4a..926d93b6e 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -15,7 +15,6 @@ })(window, document, "script", "dataLayer", "GTM-M677X6V") - diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 176178b5c..954e3f93c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -16,10 +16,6 @@ :no-tabs="newDialogOptions.eventOnly" :folder-id="newDialogOptions.folderId" /> -
- - - Premium - - @@ -229,7 +217,7 @@ html { diff --git a/frontend/src/components/event/CarbonAd.vue b/frontend/src/components/event/CarbonAd.vue deleted file mode 100644 index 752782703..000000000 --- a/frontend/src/components/event/CarbonAd.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - diff --git a/frontend/src/components/event/PubliftAd.vue b/frontend/src/components/event/PubliftAd.vue deleted file mode 100644 index 2c52cd0fa..000000000 --- a/frontend/src/components/event/PubliftAd.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - diff --git a/frontend/src/components/home/Dashboard.vue b/frontend/src/components/home/Dashboard.vue index c56eebff2..d46bb9fa0 100644 --- a/frontend/src/components/home/Dashboard.vue +++ b/frontend/src/components/home/Dashboard.vue @@ -1,27 +1,8 @@ @@ -168,11 +152,10 @@ import TimezoneSelector from "./TimezoneSelector.vue" import GCalWeekSelector from "./GCalWeekSelector.vue" import { isPhone } from "@/utils" -import Advertisement from "../event/Advertisement.vue" import ExpandableSection from "../ExpandableSection.vue" import EventOptions from "./EventOptions.vue" import { timeTypes, guestUserId } from "@/constants" -import { mapState, mapGetters } from "vuex" +import { mapState } from "vuex" export default { name: "ToolRow", @@ -199,7 +182,6 @@ export default { components: { TimezoneSelector, GCalWeekSelector, - Advertisement, ExpandableSection, EventOptions, }, @@ -215,28 +197,8 @@ export default { ], }), - mounted() { - // Initialize Google Ads only for non-premium users - // if (!this.isPremiumUser) { - // this.$nextTick(() => { - // this.initializeAd() - // }) - // } - }, - - methods: { - initializeAd() { - try { - (window.adsbygoogle = window.adsbygoogle || []).push({}) - } catch (e) { - console.error('AdSense error:', e) - } - } - }, - computed: { ...mapState(["authUser"]), - ...mapGetters(["isPremiumUser"]), isPhone() { return isPhone(this.$vuetify) }, diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 216d11595..7186609fa 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -25,7 +25,6 @@ export const authTypes = Object.freeze({ GROUP_CREATE: "group-create", ADD_CALENDAR_ACCOUNT: "add-calendar-account", ADD_CALENDAR_ACCOUNT_FROM_EDIT: "add-calendar-account-from-edit", - UPGRADE: "upgrade", }) export const eventTypes = Object.freeze({ @@ -51,13 +50,6 @@ export const calendarTypes = Object.freeze({ ICS: "ics" }) -export const upgradeDialogTypes = Object.freeze({ - CREATE_EVENT: "create-event", - SCHEDULE_EVENT: "schedule-event", - UPGRADE_MANUALLY: "upgrade-manually", - REMOVE_ADS: "remove-ads", -}) - export const timeslotDurations = Object.freeze({ FIFTEEN_MINUTES: 15, THIRTY_MINUTES: 30, @@ -181,6 +173,4 @@ export const allTimezones = Object.freeze({ export const guestUserId = "000000000000000000000000" -export const numFreeEvents = 3 - export const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/ \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index dc63242a6..205f07952 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -72,16 +72,6 @@ const routes = [ name: "cookie-settings", component: () => import("@/components/CookieSettings.vue"), }, - { - path: "/stripe-redirect", - name: "stripe-redirect", - component: () => import("@/views/StripeRedirect.vue"), - }, - { - path: "/test", - name: "test", - component: () => import("@/views/Test.vue"), - }, { path: "*", name: "404", @@ -113,13 +103,6 @@ router.beforeEach(async (to, from, next) => { next() } } - - if (to.name !== "event" && to.name !== "group") { - const fusetag = window.fusetag || (window.fusetag = { que: [] }) - fusetag.que.push(function () { - fusetag.destroySticky() - }) - } }) export default router diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index a0770841a..89d229a01 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -1,7 +1,6 @@ import Vue from "vue" import Vuex from "vuex" -import { numFreeEvents, upgradeDialogTypes } from "@/constants" -import { get, isPremiumUser } from "@/utils" +import { get } from "@/utils" import { createFolder, deleteFolder, @@ -29,15 +28,6 @@ export default new Vuex.Store({ signUpFormEnabled: false, daysOnlyEnabled: true, overlayAvailabilitiesEnabled: true, - enablePaywall: true, - - // Experiments - pricingPageConversion: "control", - - // Upgrade dialog - upgradeDialogVisible: false, - upgradeDialogType: null, - upgradeDialogData: null, // New dialog newDialogOptions: { @@ -48,11 +38,7 @@ export default new Vuex.Store({ folderId: null, }, }, - getters: { - isPremiumUser(state) { - return isPremiumUser(state.authUser) - }, - }, + getters: {}, mutations: { setError(state, error) { state.error = error @@ -87,21 +73,6 @@ export default new Vuex.Store({ setOverlayAvailabilitiesEnabled(state, enabled) { state.overlayAvailabilitiesEnabled = enabled }, - setPricingPageConversion(state, conversion) { - state.pricingPageConversion = conversion - }, - setEnablePaywall(state, enabled) { - state.enablePaywall = enabled - }, - setUpgradeDialogVisible(state, visible) { - state.upgradeDialogVisible = visible - }, - setUpgradeDialogType(state, type) { - state.upgradeDialogType = type - }, - setUpgradeDialogData(state, data) { - state.upgradeDialogData = data - }, addFolder(state, folder) { state.folders.push(folder) @@ -163,21 +134,7 @@ export default new Vuex.Store({ commit("setAuthUser", authUser) }, - createNew( - { state, getters, commit, dispatch }, - { eventOnly = false, folderId = null } - ) { - if ( - state.enablePaywall && - !getters.isPremiumUser && - state.authUser?.numEventsCreated >= numFreeEvents - ) { - dispatch("showUpgradeDialog", { - type: upgradeDialogTypes.CREATE_EVENT, - }) - return - } - + createNew({ commit }, { eventOnly = false, folderId = null }) { commit("setNewDialogOptions", { show: true, contactsPayload: {}, @@ -269,16 +226,6 @@ export default new Vuex.Store({ const authUser = await get("/user/profile") commit("setAuthUser", authUser) }, - showUpgradeDialog({ commit }, { type, data = null }) { - commit("setUpgradeDialogVisible", true) - commit("setUpgradeDialogType", type) - commit("setUpgradeDialogData", data) - }, - hideUpgradeDialog({ commit }) { - commit("setUpgradeDialogVisible", false) - commit("setUpgradeDialogType", null) - commit("setUpgradeDialogData", null) - }, }, modules: {}, }) diff --git a/frontend/src/utils/general_utils.js b/frontend/src/utils/general_utils.js index 4e18a2118..379790fe0 100644 --- a/frontend/src/utils/general_utils.js +++ b/frontend/src/utils/general_utils.js @@ -213,19 +213,6 @@ export const lightOrDark = (color) => { } } -/** Returns whether the given user is a premium user */ -export const isPremiumUser = (authUser) => { - if (!authUser) return false - - if (authUser.stripeCustomerId || authUser.polarCustomerId) { - if (authUser.isPremium !== null) { - return authUser.isPremium - } - return true - } - return false -} - /** Adds an event ID to the 'eventsCreated' list in localStorage */ export const addEventToCreatedList = (eventId) => { let eventsCreated = getEventsCreated() diff --git a/frontend/src/views/Auth.vue b/frontend/src/views/Auth.vue index 32466d3fd..9db3e3e72 100644 --- a/frontend/src/views/Auth.vue +++ b/frontend/src/views/Auth.vue @@ -2,16 +2,12 @@ diff --git a/frontend/src/views/Test.vue b/frontend/src/views/Test.vue deleted file mode 100644 index cb533919c..000000000 --- a/frontend/src/views/Test.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 398b49236..5f87421eb 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -45,10 +45,6 @@ module.exports = { lg: "1024px", xl: "1280px", "2xl": "1536px", - "publift-s": "755px", - "publift-m": "995px", - "publift-l": "1225px", - "publift-xl": "1475px", }, }, plugins: [], diff --git a/server/.env.template b/server/.env.template index 988716202..038ab1a4a 100644 --- a/server/.env.template +++ b/server/.env.template @@ -56,23 +56,3 @@ SESSION_SECRET=? # - Comma-separated list of allowed origins # - For local development, set to: http://localhost:8080 CORS_ORIGINS=? - -# Polar (payments) -# - Create an Organization Access Token with scopes: checkouts:write, products:read, customer_sessions:write -# - Create one product per plan (monthly and yearly are separate products). Lifetime plans are currently disabled. -# - Add a webhook endpoint at https:///api/polar/webhook (format: Raw, API version: 2026-10) -# subscribed to: order.paid, subscription.active, subscription.past_due, subscription.revoked, subscription.migrated -POLAR_ACCESS_TOKEN=? # optional -POLAR_WEBHOOK_SECRET=? # optional -POLAR_SERVER=? # optional, "sandbox" or "production" (default: production) -POLAR_MONTHLY_PRODUCT_ID=? # optional -POLAR_YEARLY_PRODUCT_ID=? # optional -# POLAR_LIFETIME_PRODUCT_ID=? # optional -POLAR_MONTHLY_STUDENT_PRODUCT_ID=? # optional -POLAR_YEARLY_STUDENT_PRODUCT_ID=? # optional -# POLAR_LIFETIME_STUDENT_PRODUCT_ID=? # optional - -# Stripe (legacy) -# - Only used for subscribers who signed up before the move to Polar -STRIPE_API_KEY=? # optional -STRIPE_WEBHOOK_SECRET=? # optional diff --git a/server/db/users.go b/server/db/users.go index 67e2ed651..5cf74b8c5 100644 --- a/server/db/users.go +++ b/server/db/users.go @@ -36,44 +36,6 @@ func GetUserById(userId string) *models.User { return &user } -func GetUserByStripeCustomerId(stripeCustomerId string) *models.User { - result := UsersCollection.FindOne(context.Background(), bson.M{ - "stripeCustomerId": stripeCustomerId, - }) - - if result.Err() == mongo.ErrNoDocuments { - // User does not exist! - return nil - } - - // Decode result - var user models.User - if err := result.Decode(&user); err != nil { - logger.StdErr.Panicln(err) - } - - return &user -} - -func GetUserByPolarCustomerId(polarCustomerId string) *models.User { - result := UsersCollection.FindOne(context.Background(), bson.M{ - "polarCustomerId": polarCustomerId, - }) - - if result.Err() == mongo.ErrNoDocuments { - // User does not exist! - return nil - } - - // Decode result - var user models.User - if err := result.Decode(&user); err != nil { - logger.StdErr.Panicln(err) - } - - return &user -} - func GetUserByEmail(email string) *models.User { emailQuery := strings.TrimSpace(email) if emailQuery == "" { diff --git a/server/docs/docs.go b/server/docs/docs.go index 4004e90bb..146561f10 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -1,5 +1,4 @@ -// Code generated by swaggo/swag. DO NOT EDIT. - +// Package docs Code generated by swaggo/swag. DO NOT EDIT package docs import "github.com/swaggo/swag" @@ -57,41 +56,6 @@ const docTemplate = `{ } } }, - "/analytics/downgrade-user": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "analytics" - ], - "summary": "Downgrades the specified user to Schej Free", - "parameters": [ - { - "description": "Object containing the user email", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - } - } - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, "/analytics/monthly-active-event-creators": { "get": { "consumes": [ @@ -286,76 +250,6 @@ const docTemplate = `{ } } }, - "/analytics/upgrade-dialog-viewed": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "analytics" - ], - "summary": "Notifies us when user has viewed the upgrade dialog", - "parameters": [ - { - "description": "Object containing the user id", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - } - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/analytics/upgrade-user": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "analytics" - ], - "summary": "Upgrades the specified user to Schej Premium", - "parameters": [ - { - "description": "Object containing the user email", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - } - } - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, "/analytics/user/{email}": { "get": { "consumes": [ @@ -680,7 +574,8 @@ const docTemplate = `{ "type": "boolean" }, "duration": { - "type": "number" + "type": "number", + "format": "float32" }, "isSignUpForm": { "type": "boolean" @@ -853,7 +748,8 @@ const docTemplate = `{ "type": "string" }, "duration": { - "type": "number" + "type": "number", + "format": "float32" }, "name": { "type": "string" @@ -1389,196 +1285,6 @@ const docTemplate = `{ } } }, - "/polar/billing-portal": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "polar" - ], - "summary": "Gets a Polar customer portal URL for the signed in user", - "parameters": [ - { - "type": "string", - "description": "URL to return to from the portal", - "name": "returnUrl", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - } - } - } - } - } - }, - "/polar/create-checkout-session": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "polar" - ], - "summary": "Creates a Polar checkout session for the signed in user", - "parameters": [ - { - "description": "Product to purchase and URL to return to afterwards", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "originUrl": { - "type": "string" - }, - "productId": { - "type": "string" - } - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - } - } - } - } - } - }, - "/polar/fulfill-checkout": { - "post": { - "description": "Returns 202 if the payment is still being processed; the client should retry.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "polar" - ], - "summary": "Fulfills a completed Polar checkout", - "parameters": [ - { - "description": "Polar checkout ID", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "checkoutId": { - "type": "string" - } - } - } - } - ], - "responses": { - "200": { - "description": "status is \\\"fulfilled\\", - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string" - } - } - } - }, - "202": { - "description": "status is \\\"pending\\", - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string" - } - } - } - } - } - } - }, - "/polar/price": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "polar" - ], - "summary": "Gets the prices of all premium plans", - "parameters": [ - { - "type": "string", - "description": "Pricing experiment variant", - "name": "exp", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "properties": { - "monthly": { - "type": "object" - }, - "monthlyStudent": { - "type": "object" - }, - "yearly": { - "type": "object" - }, - "yearlyStudent": { - "type": "object" - } - } - } - } - } - } - }, - "/polar/webhook": { - "post": { - "consumes": [ - "application/json" - ], - "tags": [ - "polar" - ], - "summary": "Receives Polar webhook events", - "responses": { - "200": { - "description": "OK" - } - } - } - }, "/slackbot": { "post": { "consumes": [ @@ -2403,39 +2109,6 @@ const docTemplate = `{ } } } - }, - "/users/{userId}/is-premium": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "users" - ], - "summary": "Returns whether the given user is a premium user", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "userId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "properties": { - "isPremium": { - "type": "boolean" - } - } - } - } - } - } } }, "definitions": { @@ -2851,7 +2524,8 @@ const docTemplate = `{ "additionalProperties": { "type": "array", "items": { - "type": "integer" + "type": "integer", + "format": "int64" } } }, @@ -2961,9 +2635,6 @@ const docTemplate = `{ "description": "Whether the user has set a custom name for themselves, i.e. don't change their name when they sign in", "type": "boolean" }, - "isPremium": { - "type": "boolean" - }, "lastName": { "type": "string" }, @@ -2973,18 +2644,10 @@ const docTemplate = `{ "picture": { "type": "string" }, - "polarCustomerId": { - "description": "Polar customer ID", - "type": "string" - }, "primaryAccountKey": { "description": "The calendarAccountKey of the account the user first signed in with", "type": "string" }, - "stripeCustomerId": { - "description": "Stripe customer ID", - "type": "string" - }, "timezoneOffset": { "type": "integer" } diff --git a/server/docs/swagger.json b/server/docs/swagger.json index 5a04746fd..f2989c621 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -49,41 +49,6 @@ } } }, - "/analytics/downgrade-user": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "analytics" - ], - "summary": "Downgrades the specified user to Schej Free", - "parameters": [ - { - "description": "Object containing the user email", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - } - } - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, "/analytics/monthly-active-event-creators": { "get": { "consumes": [ @@ -278,76 +243,6 @@ } } }, - "/analytics/upgrade-dialog-viewed": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "analytics" - ], - "summary": "Notifies us when user has viewed the upgrade dialog", - "parameters": [ - { - "description": "Object containing the user id", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "userId": { - "type": "string" - } - } - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/analytics/upgrade-user": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "analytics" - ], - "summary": "Upgrades the specified user to Schej Premium", - "parameters": [ - { - "description": "Object containing the user email", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - } - } - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, "/analytics/user/{email}": { "get": { "consumes": [ @@ -672,7 +567,8 @@ "type": "boolean" }, "duration": { - "type": "number" + "type": "number", + "format": "float32" }, "isSignUpForm": { "type": "boolean" @@ -845,7 +741,8 @@ "type": "string" }, "duration": { - "type": "number" + "type": "number", + "format": "float32" }, "name": { "type": "string" @@ -1381,196 +1278,6 @@ } } }, - "/polar/billing-portal": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "polar" - ], - "summary": "Gets a Polar customer portal URL for the signed in user", - "parameters": [ - { - "type": "string", - "description": "URL to return to from the portal", - "name": "returnUrl", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - } - } - } - } - } - }, - "/polar/create-checkout-session": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "polar" - ], - "summary": "Creates a Polar checkout session for the signed in user", - "parameters": [ - { - "description": "Product to purchase and URL to return to afterwards", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "originUrl": { - "type": "string" - }, - "productId": { - "type": "string" - } - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - } - } - } - } - } - }, - "/polar/fulfill-checkout": { - "post": { - "description": "Returns 202 if the payment is still being processed; the client should retry.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "polar" - ], - "summary": "Fulfills a completed Polar checkout", - "parameters": [ - { - "description": "Polar checkout ID", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "checkoutId": { - "type": "string" - } - } - } - } - ], - "responses": { - "200": { - "description": "status is \\\"fulfilled\\", - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string" - } - } - } - }, - "202": { - "description": "status is \\\"pending\\", - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string" - } - } - } - } - } - } - }, - "/polar/price": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "polar" - ], - "summary": "Gets the prices of all premium plans", - "parameters": [ - { - "type": "string", - "description": "Pricing experiment variant", - "name": "exp", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "properties": { - "monthly": { - "type": "object" - }, - "monthlyStudent": { - "type": "object" - }, - "yearly": { - "type": "object" - }, - "yearlyStudent": { - "type": "object" - } - } - } - } - } - } - }, - "/polar/webhook": { - "post": { - "consumes": [ - "application/json" - ], - "tags": [ - "polar" - ], - "summary": "Receives Polar webhook events", - "responses": { - "200": { - "description": "OK" - } - } - } - }, "/slackbot": { "post": { "consumes": [ @@ -2395,39 +2102,6 @@ } } } - }, - "/users/{userId}/is-premium": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "users" - ], - "summary": "Returns whether the given user is a premium user", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "userId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "properties": { - "isPremium": { - "type": "boolean" - } - } - } - } - } - } } }, "definitions": { @@ -2843,7 +2517,8 @@ "additionalProperties": { "type": "array", "items": { - "type": "integer" + "type": "integer", + "format": "int64" } } }, @@ -2953,9 +2628,6 @@ "description": "Whether the user has set a custom name for themselves, i.e. don't change their name when they sign in", "type": "boolean" }, - "isPremium": { - "type": "boolean" - }, "lastName": { "type": "string" }, @@ -2965,18 +2637,10 @@ "picture": { "type": "string" }, - "polarCustomerId": { - "description": "Polar customer ID", - "type": "string" - }, "primaryAccountKey": { "description": "The calendarAccountKey of the account the user first signed in with", "type": "string" }, - "stripeCustomerId": { - "description": "Stripe customer ID", - "type": "string" - }, "timezoneOffset": { "type": "integer" } diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index 2e3cc2b82..bfb5d2796 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -280,6 +280,7 @@ definitions: manualAvailability: additionalProperties: items: + format: int64 type: integer type: array description: Mapping from the start date of a day to the available times for @@ -359,24 +360,16 @@ definitions: description: Whether the user has set a custom name for themselves, i.e. don't change their name when they sign in type: boolean - isPremium: - type: boolean lastName: type: string numEventsCreated: type: integer picture: type: string - polarCustomerId: - description: Polar customer ID - type: string primaryAccountKey: description: The calendarAccountKey of the account the user first signed in with type: string - stripeCustomerId: - description: Stripe customer ID - type: string timezoneOffset: type: integer type: object @@ -433,28 +426,6 @@ paths: summary: Changes the email address of the specified user tags: - analytics - /analytics/downgrade-user: - post: - consumes: - - application/json - parameters: - - description: Object containing the user email - in: body - name: payload - required: true - schema: - properties: - email: - type: string - type: object - produces: - - application/json - responses: - "200": - description: OK - summary: Downgrades the specified user to Schej Free - tags: - - analytics /analytics/monthly-active-event-creators: get: consumes: @@ -585,50 +556,6 @@ paths: summary: Notifies us when poster QR code has been scanned tags: - analytics - /analytics/upgrade-dialog-viewed: - post: - consumes: - - application/json - parameters: - - description: Object containing the user id - in: body - name: payload - required: true - schema: - properties: - userId: - type: string - type: object - produces: - - application/json - responses: - "200": - description: OK - summary: Notifies us when user has viewed the upgrade dialog - tags: - - analytics - /analytics/upgrade-user: - post: - consumes: - - application/json - parameters: - - description: Object containing the user email - in: body - name: payload - required: true - schema: - properties: - email: - type: string - type: object - produces: - - application/json - responses: - "200": - description: OK - summary: Upgrades the specified user to Schej Premium - tags: - - analytics /analytics/user/{email}: get: consumes: @@ -837,6 +764,7 @@ paths: daysOnly: type: boolean duration: + format: float32 type: number isSignUpForm: type: boolean @@ -935,6 +863,7 @@ paths: description: type: string duration: + format: float32 type: number name: type: string @@ -1302,127 +1231,6 @@ paths: summary: Import a Timeful event from a remote instance tags: - events - /polar/billing-portal: - get: - parameters: - - description: URL to return to from the portal - in: query - name: returnUrl - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - properties: - url: - type: string - type: object - summary: Gets a Polar customer portal URL for the signed in user - tags: - - polar - /polar/create-checkout-session: - post: - consumes: - - application/json - parameters: - - description: Product to purchase and URL to return to afterwards - in: body - name: payload - required: true - schema: - properties: - originUrl: - type: string - productId: - type: string - type: object - produces: - - application/json - responses: - "200": - description: OK - schema: - properties: - url: - type: string - type: object - summary: Creates a Polar checkout session for the signed in user - tags: - - polar - /polar/fulfill-checkout: - post: - consumes: - - application/json - description: Returns 202 if the payment is still being processed; the client - should retry. - parameters: - - description: Polar checkout ID - in: body - name: payload - required: true - schema: - properties: - checkoutId: - type: string - type: object - produces: - - application/json - responses: - "200": - description: status is \"fulfilled\ - schema: - properties: - status: - type: string - type: object - "202": - description: status is \"pending\ - schema: - properties: - status: - type: string - type: object - summary: Fulfills a completed Polar checkout - tags: - - polar - /polar/price: - get: - parameters: - - description: Pricing experiment variant - in: query - name: exp - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - properties: - monthly: - type: object - monthlyStudent: - type: object - yearly: - type: object - yearlyStudent: - type: object - type: object - summary: Gets the prices of all premium plans - tags: - - polar - /polar/webhook: - post: - consumes: - - application/json - responses: - "200": - description: OK - summary: Receives Polar webhook events - tags: - - polar /slackbot: post: consumes: @@ -1957,25 +1765,4 @@ paths: summary: Returns a minimal public user profile (safe for unauthenticated clients) tags: - users - /users/{userId}/is-premium: - get: - parameters: - - description: User ID - in: path - name: userId - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - properties: - isPremium: - type: boolean - type: object - summary: Returns whether the given user is a premium user - tags: - - users swagger: "2.0" diff --git a/server/go.mod b/server/go.mod index 2cbc99600..a8090c043 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,7 +4,6 @@ go 1.20 require ( cloud.google.com/go/cloudtasks v1.12.6 - github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 github.com/brianvoe/sjwt v0.5.1 github.com/bwmarrin/discordgo v0.27.1 github.com/gin-contrib/cors v1.4.0 @@ -21,8 +20,6 @@ require ( gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) -require github.com/stripe/stripe-go/v82 v82.0.0 // indirect - require ( cloud.google.com/go/compute v1.23.3 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect diff --git a/server/go.sum b/server/go.sum index 500c2fca5..7daf5db80 100644 --- a/server/go.sum +++ b/server/go.sum @@ -10,10 +10,6 @@ cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXE github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/brianvoe/sjwt v0.5.1 h1:OKwnUrrVMnP81N9S5+ylgZECUEwW4Uw6W6J0FgcIZfw= github.com/brianvoe/sjwt v0.5.1/go.mod h1:GsyrNi4zWvWAcsVGNNMULQ8SfDMmJ2ybzAyPjNQJJL8= github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= @@ -89,7 +85,6 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91 github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= github.com/go-playground/validator/v10 v10.15.0 h1:nDU5XeOKtB3GEa+uB7GNYwhVKsgjAR7VgKoNB6ryXfw= github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= @@ -187,7 +182,6 @@ github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZO github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -206,8 +200,6 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stripe/stripe-go/v82 v82.0.0 h1:xX5JcSg/WHo4D4g+/Ltlc3AqjKJWceKDxVcg0Qn+ws4= -github.com/stripe/stripe-go/v82 v82.0.0/go.mod h1:xSOOr6hyFiNWFs9KnOMeYdLrdWOPrnKV/qiTuqGYD+8= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= @@ -256,8 +248,6 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -277,8 +267,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -303,8 +291,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/server/main.go b/server/main.go index b038861e4..19004828c 100644 --- a/server/main.go +++ b/server/main.go @@ -19,7 +19,6 @@ import ( "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/joho/godotenv" - "github.com/stripe/stripe-go/v82" "schej.it/server/db" "schej.it/server/logger" "schej.it/server/routes" @@ -132,8 +131,6 @@ func main() { routes.InitUsers(apiRouter) routes.InitEvents(apiRouter) routes.InitAnalytics(apiRouter) - routes.InitStripe(apiRouter) - routes.InitPolar(apiRouter) routes.InitFolders(apiRouter) slackbot.InitSlackbot(apiRouter) @@ -190,9 +187,6 @@ func loadDotEnv() { logger.StdOut.Println("No .env file found, using environment variables") } - // Load stripe key - stripe.Key = os.Getenv("STRIPE_API_KEY") - // Validate session secret validateSessionSecret() } @@ -222,8 +216,6 @@ func noRouteHandler() gin.HandlerFunc { eventId := path[match[2]:match[3]] event := db.GetEventByEitherId(eventId) - // params["enableStickyFooter"] = true - if event != nil { title := fmt.Sprintf("%s - Timeful (formerly Schej)", event.Name) params["title"] = title @@ -233,9 +225,6 @@ func noRouteHandler() gin.HandlerFunc { params["ogImage"] = "/img/when2meetOgImage2.png" } } - } else if regexp.MustCompile(`\/g\/`).MatchString(path) { - // /g/ routes - // params["enableStickyFooter"] = true } c.HTML(http.StatusOK, "index.html", params) diff --git a/server/models/user.go b/server/models/user.go index 597d94d12..d106fce6d 100644 --- a/server/models/user.go +++ b/server/models/user.go @@ -31,25 +31,7 @@ type User struct { // Calendar options CalendarOptions *CalendarOptions `json:"calendarOptions" bson:"calendarOptions,omitempty"` - // Stripe customer ID - StripeCustomerId *string `json:"stripeCustomerId" bson:"stripeCustomerId,omitempty"` - // Polar customer ID - PolarCustomerId *string `json:"polarCustomerId" bson:"polarCustomerId,omitempty"` - IsPremium *bool `json:"isPremium" bson:"isPremium,omitempty"` - NumEventsCreated int `json:"numEventsCreated" bson:"numEventsCreated,omitempty"` -} - -// HasPremium returns whether the user has an active premium purchase through -// either billing provider. Users with a customer ID but no explicit isPremium -// value are treated as premium (e.g. lifetime purchases predating isPremium). -func (u *User) HasPremium() bool { - if u.StripeCustomerId == nil && u.PolarCustomerId == nil { - return false - } - if u.IsPremium != nil { - return *u.IsPremium - } - return true + NumEventsCreated int `json:"numEventsCreated" bson:"numEventsCreated,omitempty"` } // Declare the possible types of TokenOrigin diff --git a/server/routes/analytics.go b/server/routes/analytics.go index 8fb93e26c..e0a1b8a12 100644 --- a/server/routes/analytics.go +++ b/server/routes/analytics.go @@ -37,11 +37,8 @@ func InitAnalytics(router *gin.RouterGroup) { analyticsRouter := router.Group("/analytics") analyticsRouter.POST("/scanned-poster", scannedPoster) - analyticsRouter.POST("/upgrade-dialog-viewed", upgradeDialogViewed) analyticsRouter.GET("/monthly-active-event-creators", AnalyticsBasicAuth(), getMonthlyActiveEventCreators) analyticsRouter.GET("/monthly-active-event-creators-with-more-than-x-events", AnalyticsBasicAuth(), getMonthlyActiveEventCreatorsWithMoreThanXEvents) - analyticsRouter.POST("/upgrade-user", AnalyticsBasicAuth(), upgradeUser) - analyticsRouter.POST("/downgrade-user", AnalyticsBasicAuth(), downgradeUser) analyticsRouter.POST("/change-email", AnalyticsBasicAuth(), changeUserEmail) analyticsRouter.GET("/user/:email", AnalyticsBasicAuth(), getUserByEmail) } @@ -80,39 +77,6 @@ func scannedPoster(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) } -// @Summary Notifies us when user has viewed the upgrade dialog -// @Tags analytics -// @Accept json -// @Produce json -// @Param payload body object{userId=string} true "Object containing the user id" -// @Success 200 -// @Router /analytics/upgrade-dialog-viewed [post] -func upgradeDialogViewed(c *gin.Context) { - payload := struct { - UserId string `json:"userId" binding:"required"` - Price string `json:"price" binding:"required"` - Type string `json:"type" binding:"required"` - }{} - if err := c.BindJSON(&payload); err != nil { - return - } - - var message string - user := db.GetUserById(payload.UserId) - if user == nil { - message = fmt.Sprintf(":eyes: %s viewed the upgrade dialog (%s), type: %s", payload.UserId, payload.Price, payload.Type) - } else { - message = fmt.Sprintf(":eyes: %s %s (%s) viewed the upgrade dialog (%s), type: %s", user.FirstName, user.LastName, user.Email, payload.Price, payload.Type) - } - - slackbot.SendTextMessageWithType( - message, - slackbot.MONETIZATION, - ) - - c.JSON(http.StatusOK, gin.H{}) -} - // @Summary Gets the daily count of monthly active event creators over a date range // @Tags analytics // @Accept json @@ -262,60 +226,6 @@ func getMonthlyActiveEventCreatorsWithMoreThanXEvents(c *gin.Context) { c.JSON(http.StatusOK, results) } -// @Summary Upgrades the specified user to Schej Premium -// @Tags analytics -// @Accept json -// @Produce json -// @Param payload body object{email=string} true "Object containing the user email" -// @Success 200 -// @Router /analytics/upgrade-user [post] -func upgradeUser(c *gin.Context) { - payload := struct { - Email string `json:"email" binding:"required"` - }{} - if err := c.BindJSON(&payload); err != nil { - return - } - - user := db.GetUserByEmail(payload.Email) - if user == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) - return - } - - stripeCustomerId := "premium" - user.StripeCustomerId = &stripeCustomerId - db.UsersCollection.UpdateOne(context.Background(), bson.M{"_id": user.Id}, bson.M{"$set": user}) - - c.JSON(http.StatusOK, gin.H{}) -} - -// @Summary Downgrades the specified user to Schej Free -// @Tags analytics -// @Accept json -// @Produce json -// @Param payload body object{email=string} true "Object containing the user email" -// @Success 200 -// @Router /analytics/downgrade-user [post] -func downgradeUser(c *gin.Context) { - payload := struct { - Email string `json:"email" binding:"required"` - }{} - if err := c.BindJSON(&payload); err != nil { - return - } - - user := db.GetUserByEmail(payload.Email) - if user == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) - return - } - - db.UsersCollection.UpdateOne(context.Background(), bson.M{"_id": user.Id}, bson.M{"$unset": bson.M{"stripeCustomerId": ""}}) - - c.JSON(http.StatusOK, gin.H{}) -} - // @Summary Changes the email address of the specified user // @Tags analytics // @Accept json diff --git a/server/routes/events.go b/server/routes/events.go index 2a02eaa0a..1f2c809e9 100644 --- a/server/routes/events.go +++ b/server/routes/events.go @@ -1891,7 +1891,7 @@ func shouldKeepGroupResponseUserEmails(event *models.Event, userSesh string, isO } // stripSensitiveUserFields removes fields from a User that should never be -// exposed in the event page API response (calendar accounts, billing info, etc.). +// exposed in the event page API response (calendar accounts, etc.). // Email is NOT stripped here as callers handle email visibility separately based // on the collectEmails setting and owner status. func stripSensitiveUserFields(user *models.User) { @@ -1900,8 +1900,6 @@ func stripSensitiveUserFields(user *models.User) { } user.CalendarAccounts = nil user.CalendarOptions = nil - user.StripeCustomerId = nil - user.PolarCustomerId = nil user.PrimaryAccountKey = nil } diff --git a/server/routes/polar.go b/server/routes/polar.go deleted file mode 100644 index a512a9f40..000000000 --- a/server/routes/polar.go +++ /dev/null @@ -1,465 +0,0 @@ -package routes - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "os" - "sync" - "time" - - "github.com/gin-gonic/gin" - stripesubscription "github.com/stripe/stripe-go/v82/subscription" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" - "schej.it/server/db" - "schej.it/server/logger" - "schej.it/server/middleware" - "schej.it/server/models" - "schej.it/server/services/polar" - "schej.it/server/slackbot" - "schej.it/server/utils" -) - -func InitPolar(router *gin.RouterGroup) { - polarRouter := router.Group("/polar") - - polarRouter.GET("/price", getPolarPrice) - polarRouter.POST("/create-checkout-session", middleware.AuthRequired(), createPolarCheckoutSession) - polarRouter.POST("/fulfill-checkout", fulfillPolarCheckout) - polarRouter.POST("/webhook", polarWebhook) - polarRouter.GET("/billing-portal", middleware.AuthRequired(), getPolarBillingPortalUrl) -} - -// Maps each plan key returned by /polar/price to the env var holding its Polar product ID -var polarPlanProductEnvVars = map[string]string{ - // Lifetime plans are disabled - // "lifetime": "POLAR_LIFETIME_PRODUCT_ID", - // "lifetimeStudent": "POLAR_LIFETIME_STUDENT_PRODUCT_ID", - "monthly": "POLAR_MONTHLY_PRODUCT_ID", - "yearly": "POLAR_YEARLY_PRODUCT_ID", - "monthlyStudent": "POLAR_MONTHLY_STUDENT_PRODUCT_ID", - "yearlyStudent": "POLAR_YEARLY_STUDENT_PRODUCT_ID", -} - -// Human-readable plan names used in Slack messages -var polarPlanDescriptions = map[string]string{ - "lifetime": "lifetime", - "monthly": "monthly", - "yearly": "yearly", - "lifetimeStudent": "lifetime student", - "monthlyStudent": "monthly student", - "yearlyStudent": "yearly student", -} - -func getPolarPlanDescription(productId string) string { - for plan, envVar := range polarPlanProductEnvVars { - if productId != "" && os.Getenv(envVar) == productId { - return polarPlanDescriptions[plan] - } - } - return productId -} - -// Price in the same shape the frontend previously received from Stripe -type polarPriceRecurring struct { - Interval string `json:"interval"` -} - -type polarPriceResponse struct { - Id string `json:"id"` - UnitAmount int64 `json:"unit_amount"` - Recurring *polarPriceRecurring `json:"recurring"` -} - -const polarPriceCacheTTL = 10 * time.Minute - -var polarPriceCache struct { - sync.Mutex - prices map[string]polarPriceResponse - fetchedAt time.Time -} - -func fetchPolarPrices() (map[string]polarPriceResponse, error) { - polarPriceCache.Lock() - defer polarPriceCache.Unlock() - - if polarPriceCache.prices != nil && time.Since(polarPriceCache.fetchedAt) < polarPriceCacheTTL { - return polarPriceCache.prices, nil - } - - prices := make(map[string]polarPriceResponse) - for plan, envVar := range polarPlanProductEnvVars { - product, err := polar.GetProduct(os.Getenv(envVar)) - if err != nil { - return nil, fmt.Errorf("fetching %s product: %w", plan, err) - } - amount, ok := product.FixedPrice() - if !ok { - return nil, fmt.Errorf("%s product %s has no fixed price", plan, product.Id) - } - var recurring *polarPriceRecurring - if product.RecurringInterval != nil { - recurring = &polarPriceRecurring{Interval: *product.RecurringInterval} - } - prices[plan] = polarPriceResponse{Id: product.Id, UnitAmount: amount, Recurring: recurring} - } - - polarPriceCache.prices = prices - polarPriceCache.fetchedAt = time.Now() - return prices, nil -} - -// @Summary Gets the prices of all premium plans -// @Tags polar -// @Produce json -// @Param exp query string false "Pricing experiment variant" -// @Success 200 {object} object{monthly=object,yearly=object,monthlyStudent=object,yearlyStudent=object} -// @Router /polar/price [get] -func getPolarPrice(c *gin.Context) { - prices, err := fetchPolarPrices() - if err != nil { - logger.StdErr.Printf("Error fetching Polar prices: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch price"}) - return - } - c.JSON(http.StatusOK, prices) -} - -type PolarCheckoutSessionPayload struct { - ProductID string `json:"productId" binding:"required"` - OriginURL string `json:"originUrl" binding:"required"` -} - -// Returns the /stripe-redirect URL the user lands on after checkout -func buildUpgradeRedirectUrl(upgradeStatus string, finalRedirectURL string) (string, error) { - redirectURL, err := url.Parse(utils.GetBaseUrl()) - if err != nil { - return "", err - } - redirectURL.Path = "/stripe-redirect" - query := url.Values{} - query.Set("upgrade", upgradeStatus) - query.Set("redirect_url", finalRedirectURL) - redirectURL.RawQuery = query.Encode() - return redirectURL.String(), nil -} - -// Returns the client IP if it is public, so Polar can detect the customer's country -func publicClientIp(c *gin.Context) string { - ip := net.ParseIP(c.ClientIP()) - if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() { - return "" - } - return ip.String() -} - -// @Summary Creates a Polar checkout session for the signed in user -// @Tags polar -// @Accept json -// @Produce json -// @Param payload body object{productId=string,originUrl=string} true "Product to purchase and URL to return to afterwards" -// @Success 200 {object} object{url=string} -// @Router /polar/create-checkout-session [post] -func createPolarCheckoutSession(c *gin.Context) { - var payload PolarCheckoutSessionPayload - if err := c.ShouldBindJSON(&payload); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload", "details": err.Error()}) - return - } - user := c.MustGet("authUser").(*models.User) - - successURL, err := buildUpgradeRedirectUrl("success", payload.OriginURL) - if err != nil { - logger.StdErr.Printf("Error building redirect URL: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error configuring redirect"}) - return - } - cancelURL, _ := buildUpgradeRedirectUrl("cancel", payload.OriginURL) - - userId := user.Id.Hex() - checkout, err := polar.CreateCheckout(polar.CheckoutCreate{ - Products: []string{payload.ProductID}, - ExternalCustomerId: userId, - CustomerEmail: user.Email, - CustomerName: fmt.Sprintf("%s %s", user.FirstName, user.LastName), - CustomerIpAddress: publicClientIp(c), - Metadata: map[string]any{"userId": userId}, - // Polar substitutes {CHECKOUT_ID} with the checkout session ID - SuccessUrl: successURL + "&checkout_id={CHECKOUT_ID}", - ReturnUrl: cancelURL, - }) - if err != nil { - logger.StdErr.Printf("Error creating Polar checkout: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create checkout session"}) - return - } - - c.JSON(http.StatusOK, gin.H{"url": checkout.Url}) -} - -type FulfillPolarCheckoutPayload struct { - CheckoutID string `json:"checkoutId" binding:"required"` -} - -// @Summary Fulfills a completed Polar checkout -// @Description Returns 202 if the payment is still being processed; the client should retry. -// @Tags polar -// @Accept json -// @Param payload body object{checkoutId=string} true "Polar checkout ID" -// @Produce json -// @Success 200 {object} object{status=string} "status is \"fulfilled\"" -// @Success 202 {object} object{status=string} "status is \"pending\"" -// @Router /polar/fulfill-checkout [post] -func fulfillPolarCheckout(c *gin.Context) { - var payload FulfillPolarCheckoutPayload - if err := c.ShouldBindJSON(&payload); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload", "details": err.Error()}) - return - } - - checkout, err := polar.GetCheckout(payload.CheckoutID) - if err != nil { - var apiErr *polar.APIError - if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "Checkout not found"}) - return - } - logger.StdErr.Printf("Error getting Polar checkout: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get checkout"}) - return - } - - switch checkout.Status { - case "succeeded": - userId := utils.Coalesce(checkout.ExternalCustomerId) - if userId == "" { - userId = metadataString(checkout.Metadata, "userId") - } - fulfillPolarPurchase(userId, utils.Coalesce(checkout.CustomerId), utils.Coalesce(checkout.ProductId), checkout.TotalAmount) - c.JSON(http.StatusOK, gin.H{"status": "fulfilled"}) - case "confirmed": - // Payment is still processing - c.JSON(http.StatusAccepted, gin.H{"status": "pending"}) - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "Checkout not completed", "status": checkout.Status}) - } -} - -func metadataString(metadata map[string]any, key string) string { - if value, ok := metadata[key].(string); ok { - return value - } - return "" -} - -// fulfillPolarPurchase upgrades the user to premium. It is safe to call multiple -// times (e.g. from both the checkout redirect and the order.paid webhook): the -// Slack notification is only sent the first time a Polar customer is linked. -func fulfillPolarPurchase(userId string, polarCustomerId string, productId string, totalAmount int64) { - userIdObj, err := primitive.ObjectIDFromHex(userId) - if err != nil || polarCustomerId == "" { - logger.StdErr.Printf("Cannot fulfill Polar purchase: userId=%q customerId=%q", userId, polarCustomerId) - return - } - - // Link the Polar customer, only matching if it isn't linked yet - result, err := db.UsersCollection.UpdateOne(context.Background(), - bson.M{"_id": userIdObj, "polarCustomerId": bson.M{"$ne": polarCustomerId}}, - bson.M{"$set": bson.M{"polarCustomerId": polarCustomerId, "isPremium": true}}, - ) - if err != nil { - logger.StdErr.Printf("Error fulfilling Polar purchase: %v", err) - return - } - - if result.ModifiedCount == 0 { - // Already linked (e.g. a renewal), just make sure the user is premium - db.UsersCollection.UpdateOne(context.Background(), bson.M{"_id": userIdObj}, bson.M{"$set": bson.M{"isPremium": true}}) - return - } - - logger.StdOut.Printf("Fulfilled Polar purchase for user %s (customer %s)\n", userId, polarCustomerId) - user := db.GetUserById(userId) - if user == nil { - return - } - amount := float32(totalAmount) / 100.0 - message := fmt.Sprintf(":moneybag: %s %s (%s) paid for Schej ($%.2f, %s) :moneybag:", user.FirstName, user.LastName, user.Email, amount, getPolarPlanDescription(productId)) - slackbot.SendTextMessageWithType(message, slackbot.MONETIZATION) -} - -// setPolarCustomerPremium sets isPremium for the user linked to a Polar customer -func setPolarCustomerPremium(polarCustomerId string, isPremium bool) *models.User { - user := db.GetUserByPolarCustomerId(polarCustomerId) - if user == nil { - logger.StdErr.Printf("No user found for Polar customer %s", polarCustomerId) - return nil - } - db.UsersCollection.UpdateOne(context.Background(), bson.M{"_id": user.Id}, bson.M{"$set": bson.M{"isPremium": isPremium}}) - return user -} - -// linkMigratedSubscription links a subscription migrated from Stripe to the -// user that owned it, so later Polar events (renewals, failed payments, -// cancellations) find the user by their Polar customer ID. -func linkMigratedSubscription(sub *polar.Subscription) error { - var user *models.User - if sub.Provider == "stripe" && sub.ProviderSubscriptionId != "" { - stripeSub, err := stripesubscription.Get(sub.ProviderSubscriptionId, nil) - if err != nil { - return fmt.Errorf("getting Stripe subscription %s: %w", sub.ProviderSubscriptionId, err) - } - if stripeSub.Customer != nil { - user = db.GetUserByStripeCustomerId(stripeSub.Customer.ID) - } - } - if user == nil && sub.Customer.Email != "" { - // Fall back to the email the Stripe customer was imported with - user = db.GetUserByEmail(sub.Customer.Email) - } - if user == nil { - logger.StdErr.Printf("No user found for migrated subscription %s (%s %s, %s)", sub.Id, sub.Provider, sub.ProviderSubscriptionId, sub.Customer.Email) - slackbot.SendTextMessageWithType(fmt.Sprintf(":warning: Could not find user for subscription migrated to Polar (%s, %s). Link polarCustomerId %s manually.", sub.ProviderSubscriptionId, sub.Customer.Email, sub.CustomerId), slackbot.MONETIZATION) - return nil - } - - _, err := db.UsersCollection.UpdateOne(context.Background(), bson.M{"_id": user.Id}, bson.M{"$set": bson.M{"polarCustomerId": sub.CustomerId, "isPremium": true}}) - if err != nil { - return err - } - logger.StdOut.Printf("Linked migrated subscription %s to user %s (Polar customer %s)\n", sub.Id, user.Id.Hex(), sub.CustomerId) - return nil -} - -// @Summary Receives Polar webhook events -// @Tags polar -// @Accept json -// @Success 200 -// @Router /polar/webhook [post] -func polarWebhook(c *gin.Context) { - const MaxBodyBytes = int64(65536) - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxBodyBytes) - - body, err := io.ReadAll(c.Request.Body) - if err != nil { - logger.StdErr.Printf("Error reading request body: %v", err) - c.AbortWithStatus(http.StatusServiceUnavailable) - return - } - - secret := os.Getenv("POLAR_WEBHOOK_SECRET") - if secret == "" { - logger.StdErr.Println("POLAR_WEBHOOK_SECRET not set") - c.AbortWithStatus(http.StatusInternalServerError) - return - } - if err := polar.VerifyWebhook(body, c.Request.Header, secret); err != nil { - logger.StdErr.Printf("Error verifying Polar webhook signature: %v", err) - c.AbortWithStatus(http.StatusForbidden) - return - } - - var event polar.WebhookEvent - if err := json.Unmarshal(body, &event); err != nil { - logger.StdErr.Printf("Error parsing Polar webhook JSON: %v", err) - c.AbortWithStatus(http.StatusBadRequest) - return - } - - switch event.Type { - case "order.paid": - var order polar.Order - if err := json.Unmarshal(event.Data, &order); err != nil { - logger.StdErr.Printf("Error parsing Polar order: %v", err) - c.AbortWithStatus(http.StatusBadRequest) - return - } - userId := utils.Coalesce(order.Customer.ExternalId) - if userId == "" { - userId = metadataString(order.Metadata, "userId") - } - if userId == "" { - // Customers migrated from Stripe have no external ID, but were linked - // to a user by the subscription.migrated handler - if user := db.GetUserByPolarCustomerId(order.CustomerId); user != nil { - userId = user.Id.Hex() - } - } - fulfillPolarPurchase(userId, order.CustomerId, utils.Coalesce(order.ProductId), order.TotalAmount) - - case "subscription.migrated": - var sub polar.Subscription - if err := json.Unmarshal(event.Data, &sub); err != nil { - logger.StdErr.Printf("Error parsing Polar subscription: %v", err) - c.AbortWithStatus(http.StatusBadRequest) - return - } - if err := linkMigratedSubscription(&sub); err != nil { - // Return an error so Polar retries the delivery - logger.StdErr.Printf("Error linking migrated Polar subscription %s: %v", sub.Id, err) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - - case "subscription.active", "subscription.past_due", "subscription.revoked": - var sub polar.Subscription - if err := json.Unmarshal(event.Data, &sub); err != nil { - logger.StdErr.Printf("Error parsing Polar subscription: %v", err) - c.AbortWithStatus(http.StatusBadRequest) - return - } - - isPremium := event.Type == "subscription.active" - user := setPolarCustomerPremium(sub.CustomerId, isPremium) - if user == nil { - break - } - - switch event.Type { - case "subscription.past_due": - logger.StdOut.Printf("Polar customer %s failed to pay for Schej!\n", sub.CustomerId) - message := fmt.Sprintf(":x: %s %s (%s) failed to pay for Schej :x:", user.FirstName, user.LastName, user.Email) - slackbot.SendTextMessageWithType(message, slackbot.MONETIZATION) - case "subscription.revoked": - logger.StdOut.Printf("Polar customer %s subscription ended!\n", sub.CustomerId) - message := fmt.Sprintf(":x: %s %s (%s) cancelled their subscription :x:", user.FirstName, user.LastName, user.Email) - slackbot.SendTextMessageWithType(message, slackbot.MONETIZATION) - } - } - - c.Status(http.StatusOK) -} - -// @Summary Gets a Polar customer portal URL for the signed in user -// @Tags polar -// @Produce json -// @Param returnUrl query string false "URL to return to from the portal" -// @Success 200 {object} object{url=string} -// @Router /polar/billing-portal [get] -func getPolarBillingPortalUrl(c *gin.Context) { - user := c.MustGet("authUser").(*models.User) - - if user.PolarCustomerId == nil || *user.PolarCustomerId == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "User has no Polar customer ID"}) - return - } - - returnURL := c.Query("returnUrl") - if returnURL == "" { - returnURL = utils.GetBaseUrl() - } - - portalURL, err := polar.CreateCustomerPortalUrl(*user.PolarCustomerId, returnURL) - if err != nil { - logger.StdErr.Printf("Error creating Polar customer session: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create billing portal session"}) - return - } - c.JSON(http.StatusOK, gin.H{"url": portalURL}) -} diff --git a/server/routes/stripe.go b/server/routes/stripe.go deleted file mode 100644 index a0f88e8b8..000000000 --- a/server/routes/stripe.go +++ /dev/null @@ -1,239 +0,0 @@ -package routes - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - - "github.com/gin-gonic/gin" - "github.com/stripe/stripe-go/v82" - portalsession "github.com/stripe/stripe-go/v82/billingportal/session" - "github.com/stripe/stripe-go/v82/checkout/session" - "github.com/stripe/stripe-go/v82/webhook" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" - "schej.it/server/db" - "schej.it/server/logger" - "schej.it/server/middleware" - "schej.it/server/models" - "schej.it/server/slackbot" - "schej.it/server/utils" -) - -// Stripe is kept for legacy subscribers whose subscriptions still renew on -// Stripe. New purchases go through Polar (see polar.go). -func InitStripe(router *gin.RouterGroup) { - stripeRouter := router.Group("/stripe") - - stripeRouter.POST("/fulfill-checkout", fulfillCheckout) - stripeRouter.POST("/webhook", stripeWebhook) - stripeRouter.GET("/billing-portal", middleware.AuthRequired(), getBillingPortalUrl) -} - -type FulfillCheckoutPayload struct { - SessionID string `json:"sessionId" binding:"required"` -} - -func fulfillCheckout(c *gin.Context) { - var payload FulfillCheckoutPayload - if err := c.ShouldBindJSON(&payload); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload", "details": err.Error()}) - return - } - - _fulfillCheckout(payload.SessionID) -} - -func _fulfillCheckout(sessionId string) { - // TODO: Make this function safe to run multiple times, - // even concurrently, with the same session ID - - // TODO: Make sure fulfillment hasn't already been - // performed for this Checkout Session - - // Retrieve the Checkout Session from the API with line_items expanded - params := &stripe.CheckoutSessionParams{} - params.AddExpand("line_items") - - cs, _ := session.Get(sessionId, params) - - // Check the Checkout Session's payment_status property - // to determine if fulfillment should be performed - if cs.PaymentStatus != stripe.CheckoutSessionPaymentStatusUnpaid { - logger.StdOut.Println("Fulfilling Checkout Session " + sessionId) - if cs.Customer != nil { - logger.StdOut.Println("Setting stripe customer ID", cs.Customer.ID) - - // Fetch user from database - userId := cs.ClientReferenceID - userIdObj, err := primitive.ObjectIDFromHex(userId) - if err != nil { - logger.StdErr.Printf("Error parsing user ID: %v", err) - return - } - user := db.GetUserById(userId) - if user == nil { - logger.StdErr.Printf("Error getting user: %v", err) - return - } - - // Only upgrade the user if customer ID is different - if user.StripeCustomerId == nil || *user.StripeCustomerId != cs.Customer.ID { - if cs.LineItems != nil && len(cs.LineItems.Data) > 0 { - price := cs.LineItems.Data[0].Price - priceId := price.ID - priceDescription := "" - if priceId == os.Getenv("STRIPE_LIFETIME_PRICE_ID") { - priceDescription = "lifetime" - } else if priceId == os.Getenv("STRIPE_MONTHLY_PRICE_ID") { - priceDescription = "monthly" - } else if priceId == os.Getenv("STRIPE_YEARLY_PRICE_ID") { - priceDescription = "yearly" - } else if priceId == os.Getenv("STRIPE_LIFETIME_STUDENT_PRICE_ID") { - priceDescription = "lifetime student" - } else if priceId == os.Getenv("STRIPE_MONTHLY_STUDENT_PRICE_ID") { - priceDescription = "monthly student" - } - amountTotal := float32(cs.LineItems.Data[0].AmountTotal) / 100.0 - - message := fmt.Sprintf(":moneybag: %s %s (%s) paid for Schej ($%.2f, %s) :moneybag:", user.FirstName, user.LastName, user.Email, amountTotal, priceDescription) - slackbot.SendTextMessageWithType(message, slackbot.MONETIZATION) - } - - user.StripeCustomerId = &cs.Customer.ID - user.IsPremium = utils.TruePtr() - db.UsersCollection.UpdateOne(context.Background(), bson.M{"_id": userIdObj}, bson.M{"$set": user}) - } - } - } -} - -func stripeWebhook(c *gin.Context) { - const MaxBodyBytes = int64(65536) - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxBodyBytes) - - body, err := io.ReadAll(c.Request.Body) - if err != nil { - logger.StdErr.Printf("Error reading request body: %v", err) - c.AbortWithStatus(http.StatusServiceUnavailable) - return - } - - // Pass the request body and Stripe-Signature header to ConstructEvent, along with the webhook signing key. - // Use the secret provided by your webhook endpoint settings or Stripe CLI. - endpointSecret := os.Getenv("STRIPE_WEBHOOK_SECRET") - if endpointSecret == "" { - logger.StdErr.Println("STRIPE_WEBHOOK_SECRET not set") - c.AbortWithStatus(http.StatusInternalServerError) - return - } - event, err := webhook.ConstructEvent(body, c.GetHeader("Stripe-Signature"), endpointSecret) - - if err != nil { - logger.StdErr.Printf("Error verifying webhook signature: %v", err) - c.AbortWithStatus(http.StatusBadRequest) // Return a 400 error on a bad signature - return - } - - // Handle the event - if event.Type == stripe.EventTypeCheckoutSessionCompleted || event.Type == stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded { - var cs stripe.CheckoutSession - err := json.Unmarshal(event.Data.Raw, &cs) - if err != nil { - logger.StdErr.Printf("Error parsing webhook JSON: %v\n", err) - c.AbortWithStatus(http.StatusBadRequest) - return - } - logger.StdOut.Printf("Checkout Session %s completed!\n", cs.ID) - _fulfillCheckout(cs.ID) // Call fulfillCheckout when session is completed - } else if event.Type == stripe.EventTypeInvoicePaid { - var inv stripe.Invoice - err := json.Unmarshal(event.Data.Raw, &inv) - if err != nil { - logger.StdErr.Printf("Error parsing webhook JSON: %v\n", err) - c.AbortWithStatus(http.StatusBadRequest) - return - } - db.UsersCollection.UpdateOne(context.Background(), bson.M{"stripeCustomerId": inv.Customer.ID}, bson.M{"$set": bson.M{"isPremium": true}}) - logger.StdOut.Printf("Customer %s renewed Schej!\n", inv.Customer.ID) - } else if event.Type == stripe.EventTypeInvoicePaymentFailed { - var inv stripe.Invoice - err := json.Unmarshal(event.Data.Raw, &inv) - if err != nil { - logger.StdErr.Printf("Error parsing webhook JSON: %v\n", err) - c.AbortWithStatus(http.StatusBadRequest) - return - } - user := db.GetUserByStripeCustomerId(inv.Customer.ID) - if user == nil { - logger.StdErr.Printf("Error getting user: %v", err) - return - } - db.UsersCollection.UpdateOne(context.Background(), bson.M{"stripeCustomerId": inv.Customer.ID}, bson.M{"$set": bson.M{"isPremium": false}}) - logger.StdOut.Printf("Customer %s failed to pay for Schej!\n", inv.Customer.ID) - - message := fmt.Sprintf(":x: %s %s (%s) failed to pay for Schej :x:", user.FirstName, user.LastName, user.Email) - slackbot.SendTextMessageWithType(message, slackbot.MONETIZATION) - } else if event.Type == stripe.EventTypeCustomerSubscriptionDeleted { - var sub stripe.Subscription - err := json.Unmarshal(event.Data.Raw, &sub) - if err != nil { - logger.StdErr.Printf("Error parsing webhook JSON: %v\n", err) - c.AbortWithStatus(http.StatusBadRequest) - return - } - // Polar cancels Stripe subscriptions it takes over billing for. Premium - // continues on Polar (see the subscription.migrated handler in polar.go) - if sub.CancellationDetails != nil && strings.HasPrefix(sub.CancellationDetails.Comment, "Migrated to Polar") { - logger.StdOut.Printf("Stripe subscription %s migrated to Polar, keeping premium\n", sub.ID) - c.Status(http.StatusOK) - return - } - user := db.GetUserByStripeCustomerId(sub.Customer.ID) - if user == nil { - logger.StdErr.Printf("Error getting user: %v", err) - return - } - db.UsersCollection.UpdateOne(context.Background(), bson.M{"stripeCustomerId": sub.Customer.ID}, bson.M{"$set": bson.M{"isPremium": false}}) - logger.StdOut.Printf("Customer %s cancelled their subscription!\n", sub.Customer.ID) - - message := fmt.Sprintf(":x: %s %s (%s) cancelled their subscription :x:", user.FirstName, user.LastName, user.Email) - slackbot.SendTextMessageWithType(message, slackbot.MONETIZATION) - } - - c.Status(http.StatusOK) // Return 200 OK to acknowledge receipt of the event -} - -func getBillingPortalUrl(c *gin.Context) { - // Get authenticated user - userInterface, _ := c.Get("authUser") - user := userInterface.(*models.User) - - // The URL to which the user is redirected when they're done managing - // billing in the portal. - returnURL := c.Query("returnUrl") - if returnURL == "" { - returnURL = utils.GetBaseUrl() // Fallback to base URL if not provided - } - - // Use the authenticated user's Stripe customer ID - if user.StripeCustomerId == nil || *user.StripeCustomerId == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "User has no Stripe customer ID"}) - return - } - - params := &stripe.BillingPortalSessionParams{ - Customer: stripe.String(*user.StripeCustomerId), - ReturnURL: stripe.String(returnURL), - } - ps, err := portalsession.New(params) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create billing portal session"}) - return - } - c.JSON(http.StatusOK, gin.H{"url": ps.URL}) -} diff --git a/server/routes/users.go b/server/routes/users.go index 80924277b..ee5bbdcc3 100644 --- a/server/routes/users.go +++ b/server/routes/users.go @@ -13,29 +13,10 @@ import ( func InitUsers(router *gin.RouterGroup) { usersRouter := router.Group("/users") - usersRouter.GET("/:userId/is-premium", getIsUserPremium) - // Public profile for invite screens, ads, etc. (no auth). Must be registered - // after more specific /:userId/... routes. + // Public profile for invite screens, etc. (no auth) usersRouter.GET("/:userId", getPublicUserProfile) } -// @Summary Returns whether the given user is a premium user -// @Tags users -// @Produce json -// @Param userId path string true "User ID" -// @Success 200 {object} object{isPremium=bool} -// @Router /users/{userId}/is-premium [get] -func getIsUserPremium(c *gin.Context) { - userId := c.Param("userId") - user := db.GetUserById(userId) - if user == nil { - c.JSON(http.StatusOK, gin.H{"isPremium": false}) - return - } - - c.JSON(http.StatusOK, gin.H{"isPremium": user.HasPremium()}) -} - // @Summary Returns a minimal public user profile (safe for unauthenticated clients) // @Tags users // @Produce json diff --git a/server/services/polar/polar.go b/server/services/polar/polar.go deleted file mode 100644 index a16602434..000000000 --- a/server/services/polar/polar.go +++ /dev/null @@ -1,262 +0,0 @@ -// Package polar is a minimal client for the Polar REST API (https://polar.sh/docs/api-reference) -// along with Standard Webhooks signature verification for Polar webhooks. -package polar - -import ( - "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "strconv" - "strings" - "time" -) - -// API version all requests are pinned to -const apiVersion = "2026-10" - -var httpClient = &http.Client{Timeout: 15 * time.Second} - -func baseURL() string { - if os.Getenv("POLAR_SERVER") == "sandbox" { - return "https://sandbox-api.polar.sh/v1" - } - return "https://api.polar.sh/v1" -} - -// APIError is returned when Polar responds with a non-2xx status -type APIError struct { - StatusCode int - Body string -} - -func (e *APIError) Error() string { - return fmt.Sprintf("polar api error %d: %s", e.StatusCode, e.Body) -} - -func do(method, path string, body any, out any) error { - var reqBody io.Reader - if body != nil { - b, err := json.Marshal(body) - if err != nil { - return err - } - reqBody = bytes.NewReader(b) - } - - req, err := http.NewRequest(method, baseURL()+path, reqBody) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+os.Getenv("POLAR_ACCESS_TOKEN")) - req.Header.Set("Polar-Version", apiVersion) - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return &APIError{StatusCode: resp.StatusCode, Body: string(respBody)} - } - if out != nil { - return json.Unmarshal(respBody, out) - } - return nil -} - -// Customer is the subset of a Polar customer embedded in orders and subscriptions -type Customer struct { - Id string `json:"id"` - Email string `json:"email"` - ExternalId *string `json:"external_id"` -} - -type Price struct { - Id string `json:"id"` - AmountType string `json:"amount_type"` - PriceAmount int64 `json:"price_amount"` - IsArchived bool `json:"is_archived"` -} - -type Product struct { - Id string `json:"id"` - Name string `json:"name"` - RecurringInterval *string `json:"recurring_interval"` - Prices []Price `json:"prices"` -} - -// FixedPrice returns the amount (in cents) of the product's active fixed price -func (p *Product) FixedPrice() (int64, bool) { - for _, price := range p.Prices { - if price.AmountType == "fixed" && !price.IsArchived { - return price.PriceAmount, true - } - } - return 0, false -} - -type Checkout struct { - Id string `json:"id"` - Url string `json:"url"` - Status string `json:"status"` - CustomerId *string `json:"customer_id"` - ExternalCustomerId *string `json:"external_customer_id"` - ProductId *string `json:"product_id"` - TotalAmount int64 `json:"total_amount"` - Metadata map[string]any `json:"metadata"` -} - -type CheckoutCreate struct { - Products []string `json:"products"` - ExternalCustomerId string `json:"external_customer_id,omitempty"` - CustomerEmail string `json:"customer_email,omitempty"` - CustomerName string `json:"customer_name,omitempty"` - CustomerIpAddress string `json:"customer_ip_address,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - SuccessUrl string `json:"success_url,omitempty"` - ReturnUrl string `json:"return_url,omitempty"` -} - -type Order struct { - Id string `json:"id"` - Status string `json:"status"` - BillingReason string `json:"billing_reason"` - TotalAmount int64 `json:"total_amount"` - CustomerId string `json:"customer_id"` - ProductId *string `json:"product_id"` - Customer Customer `json:"customer"` - Metadata map[string]any `json:"metadata"` -} - -type Subscription struct { - Id string `json:"id"` - Status string `json:"status"` - CustomerId string `json:"customer_id"` - Customer Customer `json:"customer"` - // Set on subscription.migrated: the billing provider the subscription - // was migrated from (e.g. "stripe") and its ID there - Provider string `json:"provider"` - ProviderSubscriptionId string `json:"provider_subscription_id"` -} - -func CreateCheckout(params CheckoutCreate) (*Checkout, error) { - var checkout Checkout - if err := do(http.MethodPost, "/checkouts/", params, &checkout); err != nil { - return nil, err - } - return &checkout, nil -} - -func GetCheckout(id string) (*Checkout, error) { - var checkout Checkout - if err := do(http.MethodGet, "/checkouts/"+id, nil, &checkout); err != nil { - return nil, err - } - return &checkout, nil -} - -func GetProduct(id string) (*Product, error) { - var product Product - if err := do(http.MethodGet, "/products/"+id, nil, &product); err != nil { - return nil, err - } - return &product, nil -} - -// CreateCustomerPortalUrl creates a customer session and returns a pre-authenticated -// customer portal URL -func CreateCustomerPortalUrl(customerId string, returnUrl string) (string, error) { - body := map[string]any{"customer_id": customerId} - if returnUrl != "" { - body["return_url"] = returnUrl - } - var session struct { - CustomerPortalUrl string `json:"customer_portal_url"` - } - if err := do(http.MethodPost, "/customer-sessions/", body, &session); err != nil { - return "", err - } - return session.CustomerPortalUrl, nil -} - -// WebhookEvent is the envelope of every Polar webhook payload -type WebhookEvent struct { - Type string `json:"type"` - Timestamp string `json:"timestamp"` - Data json.RawMessage `json:"data"` -} - -var ErrInvalidSignature = errors.New("invalid webhook signature") - -// Webhook timestamps further than this from now are rejected to prevent replays -const webhookTolerance = 5 * time.Minute - -// VerifyWebhook verifies a Standard Webhooks signature. Polar secrets generated -// on or after 2026-09-08 use the standard key derivation (base64-decoded secret -// after the `whsec_` prefix); older secrets use the raw UTF-8 bytes of the full -// secret. Both are tried. -func VerifyWebhook(body []byte, headers http.Header, secret string) error { - return verifyWebhookAt(body, headers, secret, time.Now()) -} - -func verifyWebhookAt(body []byte, headers http.Header, secret string, now time.Time) error { - id := headers.Get("webhook-id") - timestamp := headers.Get("webhook-timestamp") - signatures := headers.Get("webhook-signature") - if id == "" || timestamp == "" || signatures == "" { - return ErrInvalidSignature - } - - ts, err := strconv.ParseInt(timestamp, 10, 64) - if err != nil { - return ErrInvalidSignature - } - diff := now.Sub(time.Unix(ts, 0)) - if diff > webhookTolerance || diff < -webhookTolerance { - return ErrInvalidSignature - } - - secret = strings.TrimSpace(secret) - keys := [][]byte{[]byte(secret)} // legacy Polar derivation - if decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")); err == nil { - keys = append([][]byte{decoded}, keys...) - } - - signedContent := []byte(id + "." + timestamp + "." + string(body)) - for _, key := range keys { - mac := hmac.New(sha256.New, key) - mac.Write(signedContent) - expected := mac.Sum(nil) - - for _, sig := range strings.Fields(signatures) { - version, value, found := strings.Cut(sig, ",") - if !found || version != "v1" { - continue - } - decoded, err := base64.StdEncoding.DecodeString(value) - if err != nil { - continue - } - if hmac.Equal(decoded, expected) { - return nil - } - } - } - return ErrInvalidSignature -} diff --git a/server/services/polar/polar_test.go b/server/services/polar/polar_test.go deleted file mode 100644 index 8dd9c9ed9..000000000 --- a/server/services/polar/polar_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package polar - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "net/http" - "strconv" - "testing" - "time" -) - -func sign(key []byte, id, timestamp string, body []byte) string { - mac := hmac.New(sha256.New, key) - mac.Write([]byte(id + "." + timestamp + "." + string(body))) - return "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) -} - -func headersFor(id string, ts time.Time, signature string) http.Header { - h := http.Header{} - h.Set("webhook-id", id) - h.Set("webhook-timestamp", strconv.FormatInt(ts.Unix(), 10)) - h.Set("webhook-signature", signature) - return h -} - -func TestVerifyWebhook(t *testing.T) { - now := time.Unix(1790000000, 0) - ts := strconv.FormatInt(now.Unix(), 10) - body := []byte(`{"type":"order.paid","data":{}}`) - - rawKey := []byte("0123456789abcdef0123456789abcdef") - standardSecret := "whsec_" + base64.StdEncoding.EncodeToString(rawKey) - legacySecret := "whsec_legacy_polar_secret" - - tests := []struct { - name string - secret string - headers http.Header - body []byte - wantErr bool - }{ - {"standard secret", standardSecret, headersFor("msg_1", now, sign(rawKey, "msg_1", ts, body)), body, false}, - {"legacy secret", legacySecret, headersFor("msg_1", now, sign([]byte(legacySecret), "msg_1", ts, body)), body, false}, - {"multiple signatures", standardSecret, headersFor("msg_1", now, "v1,bm9wZQ== "+sign(rawKey, "msg_1", ts, body)), body, false}, - {"tampered body", standardSecret, headersFor("msg_1", now, sign(rawKey, "msg_1", ts, body)), []byte(`{"type":"order.paid","data":{"x":1}}`), true}, - {"wrong secret", "whsec_" + base64.StdEncoding.EncodeToString([]byte("other")), headersFor("msg_1", now, sign(rawKey, "msg_1", ts, body)), body, true}, - {"stale timestamp", standardSecret, headersFor("msg_1", now.Add(-10*time.Minute), sign(rawKey, "msg_1", strconv.FormatInt(now.Add(-10*time.Minute).Unix(), 10), body)), body, true}, - {"missing headers", standardSecret, http.Header{}, body, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := verifyWebhookAt(tt.body, tt.headers, tt.secret, now) - if (err != nil) != tt.wantErr { - t.Fatalf("verifyWebhookAt() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} diff --git a/server/slackbot/slackbot.go b/server/slackbot/slackbot.go index 3a8eeb454..b12359b86 100644 --- a/server/slackbot/slackbot.go +++ b/server/slackbot/slackbot.go @@ -15,8 +15,7 @@ import ( type SlackbotMessageType string const ( - GENERAL SlackbotMessageType = "general" - MONETIZATION SlackbotMessageType = "monetization" + GENERAL SlackbotMessageType = "general" ) func SendTextMessage(message string) { @@ -38,9 +37,6 @@ func SendMessageWithType(message *commands.Response, messageType SlackbotMessage case GENERAL: // schej-bot webhookUrl = os.Getenv("SLACK_PROD_WEBHOOK_URL") - case MONETIZATION: - // monetization-bot - webhookUrl = os.Getenv("SLACK_MONETIZATION_WEBHOOK_URL") } } else { // schej-bot-dev From 070d4dd8fc217d1ab2242437254ccb4db691e902 Mon Sep 17 00:00:00 2001 From: jonathan liu Date: Wed, 7 Oct 2026 15:04:43 -0400 Subject: [PATCH 2/6] Add free-to-use announcement on landing page, remove "Formerly known as Schej" Co-Authored-By: Claude Opus 5.5 (1M context) --- frontend/src/components/FormerlyKnownAs.vue | 22 --------------------- frontend/src/views/Event.vue | 5 ----- frontend/src/views/Home.vue | 5 ----- frontend/src/views/Landing.vue | 17 ++++++++++++---- 4 files changed, 13 insertions(+), 36 deletions(-) delete mode 100644 frontend/src/components/FormerlyKnownAs.vue diff --git a/frontend/src/components/FormerlyKnownAs.vue b/frontend/src/components/FormerlyKnownAs.vue deleted file mode 100644 index 236ea43bb..000000000 --- a/frontend/src/components/FormerlyKnownAs.vue +++ /dev/null @@ -1,22 +0,0 @@ - - diff --git a/frontend/src/views/Event.vue b/frontend/src/views/Event.vue index 7b30adc32..89cba59b7 100644 --- a/frontend/src/views/Event.vue +++ b/frontend/src/views/Event.vue @@ -1,8 +1,5 @@