mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(billing): cancel in-app with a one-question reason
Add a "Cancel subscription" button beside "Manage Subscription" in Settings. It opens a dialog with one optional question (five answers, no default, a note box under the two that want detail), then schedules the Stripe subscription to end at the close of the current period without a trip to the portal. The answer is stored in a new subscription_cancellations table and shown, with an all-time tally, on the admin dashboard; the category is also mirrored onto Stripe's cancellation feedback, the free text stays local. The cancel route claims the local cancel flag with a conditional update before calling Stripe, so two racing requests cannot both write a reason row, and hands the claim back when Stripe refuses. A subscription Stripe no longer knows answers 409 with a pointer to the portal instead of a 500. The route carries an account-keyed rate limit on top of the shared IP one. Two fixes found on the way: the pinned Stripe API version reports current_period_end on the subscription item rather than the subscription, so the sync stored null for every period end; a shared helper now reads the item first. And the RadioGroup styles targeted a data-checked attribute radix never writes, so the checked state was invisible in the light theme.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
-- One row per in-app cancellation, carrying the single answer the customer
|
||||
-- gave on the way out. Written when the request is made, before Stripe
|
||||
-- confirms it, so a reason is never lost to a webhook that arrives late.
|
||||
CREATE TYPE "CancellationReason" AS ENUM ('NOT_USING', 'MISSING_FEATURE', 'PRICE_OR_BILLING', 'PROJECT_ENDED', 'OTHER');
|
||||
|
||||
CREATE TABLE "subscription_cancellations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"stripeSubscriptionId" TEXT NOT NULL,
|
||||
"reason" "CancellationReason",
|
||||
"note" VARCHAR(500),
|
||||
"periodEnd" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "subscription_cancellations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "subscription_cancellations_userId_createdAt_idx" ON "subscription_cancellations"("userId", "createdAt" DESC);
|
||||
CREATE INDEX "subscription_cancellations_createdAt_idx" ON "subscription_cancellations"("createdAt" DESC);
|
||||
|
||||
ALTER TABLE "subscription_cancellations" ADD CONSTRAINT "subscription_cancellations_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -54,6 +54,7 @@ model User {
|
||||
sentInvitations Invitation[] @relation("InvitationsSentBy")
|
||||
acquisition UserAcquisition?
|
||||
analyticsEvents AnalyticsEvent[]
|
||||
subscriptionCancellations SubscriptionCancellation[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -609,6 +610,36 @@ model UserFeedback {
|
||||
@@map("user_feedback")
|
||||
}
|
||||
|
||||
enum CancellationReason {
|
||||
NOT_USING
|
||||
MISSING_FEATURE
|
||||
PRICE_OR_BILLING
|
||||
PROJECT_ENDED
|
||||
OTHER
|
||||
}
|
||||
|
||||
// One row per in-app cancellation, written the moment the customer asks for
|
||||
// it, not when Stripe later confirms it. The reason is the whole point of the
|
||||
// row and it is optional on purpose: the question can be skipped, and a skipped
|
||||
// answer still counts as a cancellation whose reason is unknown rather than a
|
||||
// cancellation that never happened. Cancellations made in the Stripe portal
|
||||
// never produce a row here; the funnel event still records those.
|
||||
model SubscriptionCancellation {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
stripeSubscriptionId String
|
||||
reason CancellationReason?
|
||||
note String? @db.VarChar(500)
|
||||
// When access was due to end at the time of the request.
|
||||
periodEnd DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
@@index([createdAt(sort: Desc)])
|
||||
@@map("subscription_cancellations")
|
||||
}
|
||||
|
||||
model UserFeedbackScreenshot {
|
||||
id String @id @default(cuid())
|
||||
feedbackId String
|
||||
|
||||
Reference in New Issue
Block a user