Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/backend/src/api/routes/billing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export class BillingController {

@Get('/portal')
async modifyPayment(@GetOrgFromRequest() org: Organization) {
// Admin-granted/never-subscribed orgs have no real `cus_...` customer, so
// there is no Stripe billing portal to open (the UI hides the button; this
// guards the endpoint against a "No such customer" Stripe error).
if (!org.paymentId?.startsWith('cus_')) {
throw new HttpException('No Stripe customer for this organization', 400);
}
const customer = await this._stripeService.getCustomerByOrganizationId(
org.id
);
Expand All @@ -116,6 +122,13 @@ export class BillingController {
@GetUserFromRequest() user: User,
@Body() body: { feedback: string }
) {
// An admin-granted subscription has no Stripe subscription to cancel (its
// paymentId is the user id, not a `cus_...` customer). The UI hides the
// self-service cancel button for these; removal is an admin-only action.
Comment thread
egelhaus marked this conversation as resolved.
if (!org.paymentId?.startsWith('cus_')) {
throw new HttpException('No Stripe subscription to cancel', 400);
}

await this._notificationService.sendEmail(
process.env.EMAIL_FROM_ADDRESS,
'Subscription Cancelled',
Expand Down Expand Up @@ -168,6 +181,15 @@ export class BillingController {
throw new HttpException('Unauthorized', 400);
}

// Admin-granted subscriptions have no Stripe subscription to cancel; the
// admin panel hides this and offers "Remove Subscription" instead.
if (!org.paymentId?.startsWith('cus_')) {
throw new HttpException(
'Admin-granted subscription; use remove-subscription instead',
400
);
}

return this._stripeService.cancelSubscription(org.id);
}

Expand Down
5 changes: 5 additions & 0 deletions apps/backend/src/api/routes/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ export class UsersController {
? false
: organization?.isTrailing,
allowTrial: organization?.allowTrial,
// A real Stripe customer has a `cus_...` paymentId. Admin-granted
// subscriptions store the user id there instead, and never-subscribed
// orgs have none, so gate billing-portal UI on this to avoid calling
// Stripe with a non-existent customer ("No such customer").
hasStripeCustomer: !!organization?.paymentId?.startsWith('cus_'),
streakSince: organization?.streakSince || null,
publicApi:
// @ts-ignore
Expand Down
14 changes: 8 additions & 6 deletions apps/frontend/src/components/billing/main.billing.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -539,12 +539,14 @@ export const MainBillingComponent: FC<{
</div>
{!!subscription?.id && (
<div className="flex justify-center mt-[20px] gap-[10px]">
<Button onClick={updatePayment}>
{t(
'update_payment_method_invoices_history',
'Update Payment Method / Invoices History'
)}
</Button>
{!!(user as any)?.hasStripeCustomer && (
<Button onClick={updatePayment}>
{t(
'update_payment_method_invoices_history',
'Update Payment Method / Invoices History'
)}
</Button>
)}
{isGeneral && !subscription?.cancelAt && (
<Button
className="bg-red-500"
Expand Down
29 changes: 27 additions & 2 deletions libraries/nestjs-libraries/src/services/stripe.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ export class StripeService {

async prorate(organizationId: string, body: BillingSubscribeDto) {
const org = await this._organizationService.getOrgById(organizationId);
// Admin-granted subscriptions store the user id in paymentId (not a real
// `cus_...` customer); there is nothing to prorate against and Stripe would
// throw "No such customer", so return a zero price without calling it.
if (org?.paymentId && !org.paymentId.startsWith('cus_')) {
return { price: 0 };
}
Comment thread
egelhaus marked this conversation as resolved.
const customer = await this.createOrGetCustomer(org!);
const priceData = pricing[body.billing];
const allProducts = await stripe.products.list({
Expand Down Expand Up @@ -541,6 +547,11 @@ export class StripeService {
}

async finishTrial(paymentId: string) {
// No real Stripe customer (admin-granted or never-subscribed) → nothing to
// finish; skip the call that would throw "No such customer".
if (!paymentId?.startsWith('cus_')) {
return;
}
const list = (
await stripe.subscriptions.list({
customer: paymentId,
Expand All @@ -557,6 +568,12 @@ export class StripeService {
return false;
}

// No real Stripe customer (admin-granted or never-subscribed) → no discount
// to offer; skip the call that would throw "No such customer".
if (!customer?.startsWith('cus_')) {
return false;
}

const list = await stripe.charges.list({
customer,
limit: 1,
Expand Down Expand Up @@ -592,7 +609,12 @@ export class StripeService {
}

async applyDiscount(customer: string) {
const check = this.checkDiscount(customer);
// No real Stripe customer (admin-granted or never-subscribed) → nothing to
// apply a discount to; skip the call that would throw "No such customer".
if (!customer?.startsWith('cus_')) {
return false;
}
const check = await this.checkDiscount(customer);
if (!check) {
return false;
}
Expand Down Expand Up @@ -844,7 +866,10 @@ export class StripeService {

async getCharges(organizationId: string) {
const org = await this._organizationService.getOrgById(organizationId);
if (!org?.paymentId) {
// Only real Stripe customers have charges. Admin-granted subscriptions
// store the user id in `paymentId` (not a `cus_...` customer), so calling
// Stripe for them just throws "No such customer".
if (!org?.paymentId || !org.paymentId.startsWith('cus_')) {
return [];
}

Expand Down
Loading