mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-12 01:46:08 +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:
+21
-4
@@ -746,6 +746,26 @@ function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId:
|
||||
return subscription.items.data.some((item) => item.price.id === configuredPriceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the current billing period ends, as a Unix timestamp, or null.
|
||||
*
|
||||
* The API version this client pins (2026-02-25) reports the period on each
|
||||
* subscription item rather than on the subscription itself, and every item of
|
||||
* a single-price subscription carries the same dates. The top-level field is
|
||||
* still read afterwards so an older fixture or a replayed event body from a
|
||||
* previous version keeps working.
|
||||
*/
|
||||
export function getSubscriptionPeriodEnd(subscription: Stripe.Subscription): number | null {
|
||||
const fromItem = subscription.items?.data?.[0]?.current_period_end;
|
||||
if (typeof fromItem === 'number') {
|
||||
return fromItem;
|
||||
}
|
||||
|
||||
return 'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
|
||||
? subscription.current_period_end
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
@@ -768,10 +788,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPeriodEnd =
|
||||
'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
|
||||
? subscription.current_period_end
|
||||
: null;
|
||||
const currentPeriodEnd = getSubscriptionPeriodEnd(subscription);
|
||||
const cancelAt =
|
||||
'cancel_at' in subscription && typeof subscription.cancel_at === 'number'
|
||||
? subscription.cancel_at
|
||||
|
||||
Reference in New Issue
Block a user