Use router input for org slug and query params in subscription component, adjust subscription refresh handling

This commit is contained in:
James Kiger committed 2025-03-18 08:48:48 -04:00
1 parent d23c695415
commit 4ed619a300
6 files changed
+297 -235

No files matched your search

+2 -1
View File
@@ -34,7 +34,8 @@
"prefix": "gt",
"style": "kebab-case"
}
]
],
"@angular-eslint/no-input-rename": "off"
}
},
{
+1
View File
@@ -36,6 +36,7 @@ export class OrganizationsService {
activeOrganizationResource = resource({
request: () => ({ organization_slug: this.activeOrganizationSlug() }),
loader: async ({ request }) => {
console.log("loading for some reasn")
if (!request.organization_slug) {
return undefined;
}
@@ -1,4 +1,4 @@
import { computed, Injectable, inject, resource } from "@angular/core";
import { computed, Injectable, inject, resource, signal } from "@angular/core";
import { Router } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { tap } from "rxjs/operators";
@@ -6,12 +6,12 @@ import { EventsCount } from "./subscriptions.interfaces";
import { StatefulService } from "src/app/shared/stateful-service/signal-state.service";
import { SubscriptionsAPIService } from "./subscriptions-api.service";
import { client } from "../api";
import { OrganizationsService } from "../organizations.service";
import { SettingsService } from "../settings.service";
interface SubscriptionsState {
billingPortalLoading: boolean;
billingPortalLoadingError: string;
subscriptionRefreshing: boolean;
subscriptionLoadingTimeout: boolean;
fromStripe: boolean;
eventsCount: EventsCount | null;
@@ -20,6 +20,7 @@ interface SubscriptionsState {
const initialState: SubscriptionsState = {
billingPortalLoading: false,
billingPortalLoadingError: "",
subscriptionRefreshing: false,
subscriptionLoadingTimeout: false,
fromStripe: false,
eventsCount: null,
@@ -29,20 +30,17 @@ const initialState: SubscriptionsState = {
providedIn: "root",
})
export class SubscriptionsService extends StatefulService<SubscriptionsState> {
private organizationsService = inject(OrganizationsService);
private settingsService = inject(SettingsService);
private subscriptionsAPIService = inject(SubscriptionsAPIService);
private router = inject(Router);
stripePublicKey = this.settingsService.stripePublicKey;
subscriptionLoading = computed(() => this.subscriptionResource.isLoading());
subscriptionLoadingTimeout = computed(
() => this.state().subscriptionLoadingTimeout
);
organizationSlug = signal<string>("");
subscriptionResource = resource({
request: () => ({
orgSlug: this.organizationsService.activeOrganizationSlug(),
orgSlug: this.organizationSlug(),
}),
loader: async ({ request }) => {
if (!request.orgSlug) {
@@ -74,10 +72,18 @@ export class SubscriptionsService extends StatefulService<SubscriptionsState> {
};
return formattedSubscription;
});
subscriptionLoading = computed(
() =>
this.subscriptionResource.isLoading() ||
this.state().subscriptionRefreshing
);
subscriptionLoadingTimeout = computed(
() => this.state().subscriptionLoadingTimeout
);
eventCountResource = resource({
request: () => ({
orgSlug: this.organizationsService.activeOrganizationSlug(),
orgSlug: this.organizationSlug(),
}),
loader: async ({ request }) => {
if (!request.orgSlug) {
@@ -98,7 +104,6 @@ export class SubscriptionsService extends StatefulService<SubscriptionsState> {
return data;
},
});
eventsCountWithTotal = computed(() => {
const eventsCount = this.eventCountResource.value();
if (!eventsCount) return eventsCount;
@@ -116,7 +121,6 @@ export class SubscriptionsService extends StatefulService<SubscriptionsState> {
billingPortalLoadingError = computed(
() => this.state().billingPortalLoadingError
);
fromStripe = computed(() => this.state().fromStripe);
totalEventsAllowed = computed(() => {
const subscription = this.subscription();
@@ -127,33 +131,11 @@ export class SubscriptionsService extends StatefulService<SubscriptionsState> {
super(initialState);
}
/**
* Keep trying to get subscription, for users redirected from Stripe
* @param slug Organization Slug for requested subscription
*/
// retrieveUntilSubscriptionOrTimeout(slug: string) {
// this.setSubscriptionLoadingStart(true);
// lastValueFrom(
// this.subscriptionsAPIService.retrieve(slug).pipe(
// expand((subscription) => {
// if (!subscription.created) {
// return this.subscriptionsAPIService
// .retrieve(slug)
// .pipe(delay(2000));
// } else {
// this.setSubscription(subscription);
// return EMPTY;
// }
// }),
// catchError(() => {
// this.setSubscriptionLoadingError();
// return EMPTY;
// }),
// takeUntil(this.subscriptionRetryTimer())
// ),
// { defaultValue: null }
// );
// }
retrieveSubscriptionData(orgSlug: string) {
if (orgSlug) {
this.organizationSlug.set(orgSlug);
}
}
/**
* Retrieve Subscription and navigate to subscription page if no subscription exists
@@ -182,7 +164,7 @@ export class SubscriptionsService extends StatefulService<SubscriptionsState> {
async redirectToBillingPortal() {
this.setBillingPortalLoadingStart();
const orgSlug = this.organizationsService.activeOrganizationSlug();
const orgSlug = this.organizationSlug();
const { data, error } = await client.POST(
"/api/0/stripe/organizations/{organization_slug}/create-billing-portal/",
{
@@ -201,36 +183,55 @@ export class SubscriptionsService extends StatefulService<SubscriptionsState> {
}
}
// private subscriptionRetryTimer() {
// return timer(60000).pipe(
// tap(() => {
// this.setSubscriptionLoadingTimeout();
// })
// );
// }
/**
* Keep trying to get subscription, for users redirected from Stripe
*/
refreshUntilSubscriptionOrTimeout() {
this.setSubscriptionRefreshingStart(true);
let i = 0;
const intervalRef = setInterval(() => {
this.subscriptionResource.reload();
if (this.subscription()) {
this.setSubscriptionRefreshingComplete();
clearInterval(intervalRef);
} else if (i === 2) {
this.setSubscriptionRefreshingTimeout();
clearInterval(intervalRef);
}
i++;
}, 2000);
}
// private setSubscriptionLoadingStart(fromStripe: boolean = false) {
// this.setState({ subscriptionLoading: true, fromStripe });
// }
private setSubscriptionRefreshingStart(fromStripe: boolean = false) {
this.setState({ subscriptionRefreshing: true, fromStripe });
}
// private setSubscriptionLoadingError() {
// this.setState({ subscriptionLoading: false });
// }
private setSubscriptionRefreshingComplete(fromStripe: boolean = false) {
this.setState({ subscriptionRefreshing: false });
}
// private setSubscriptionLoadingTimeout() {
// this.setState({
// subscriptionLoadingTimeout: true,
// });
// }
private setSubscriptionRefreshingTimeout() {
this.setState({
subscriptionRefreshing: false,
subscriptionLoadingTimeout: true,
});
}
setBillingPortalLoadingStart() {
private setBillingPortalLoadingStart() {
this.setState({ billingPortalLoading: true });
}
setBillingPortalLoadingError(message: string) {
private setBillingPortalLoadingError(message: string) {
this.setState({
billingPortalLoading: false,
billingPortalLoadingError: message,
});
}
clearState() {
super.clearState();
this.organizationSlug.set("");
this.subscriptionResource.set(undefined);
this.eventCountResource.set(undefined);
}
}
@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, OnInit, inject } from "@angular/core";
import { Component, ChangeDetectionStrategy, inject } from "@angular/core";
import { environment } from "../../../../environments/environment";
import { EventInfoComponent } from "../../../shared/event-info/event-info.component";
import { MatDividerModule } from "@angular/material/divider";
@@ -23,7 +23,7 @@ import { OrganizationsService } from "src/app/api/organizations.service";
DecimalPipe,
],
})
export class PaymentComponent implements OnInit {
export class PaymentComponent {
private organizationService = inject(OrganizationsService);
private paymentService = inject(PaymentService);
@@ -32,10 +32,6 @@ export class PaymentComponent implements OnInit {
this.paymentService.subscriptionCreationLoadingId;
billingEmail = environment.billingEmail;
ngOnInit() {
this.organizationService.activeOrganizationResource.reload();
}
onSubmit(price: Price) {
const activeOrganization = this.organizationService.activeOrganization();
if (activeOrganization) {
@@ -2,142 +2,212 @@
<header class="page-header">
<h2 class="mat-headline-4" i18n>Subscription</h2>
@if (fromStripe()) {
<span class="mat-subtitle-1">Redirected from Stripe</span>
<span class="mat-subtitle-1">Redirected from Stripe</span>
}
</header>
@if (!(subscriptionLoading())) { @if (subscription(); as subscription) {
<mat-card>
<mat-card-content>
<div>
@if ( subscription.status === "active" || subscription.status ===
"trialing" || subscription.status === "past_due" ) { @if
(subscription.status === "active") {
<p class="mat-body-1">
@if (subscription.effectivePrice === 0) {
<ng-container i18n>You have selected the free plan. Please consider making a monthly
<a href="https://liberapay.com/GlitchTip/donate" target="_blank">donation</a>
to help keep open source development sustainable. Thank you for
choosing GlitchTip!</ng-container>
} @else {
<ng-container i18n>This organization has an active subscription to GlitchTip. Thank
you!</ng-container>
}
</p>
} @if (subscription.status === "trialing") {
<p class="mat-body-1 no-bottom-margin" i18n>This organization has a trial subscription to GlitchTip.</p>
} @if (subscription.status === "past_due") {
<p class="mat-body-1" i18n>This organization has a subscription to GlitchTip, but the payment is
past due.</p>
@if (subscriptionLoading() || subscriptionLoadingTimeout()) {
<mat-card>
<mat-card-header></mat-card-header>
<mat-card-content>
@if (subscriptionLoading()) {
<div class="spinner-container">
<mat-spinner diameter="36"></mat-spinner>
</div>
}
<ng-container>
<mat-card-subtitle i18n>Details</mat-card-subtitle>
<h3>{{ subscription.product.name }}</h3>
{{ subscription.product.description }}
@if (eventsPercent(); as eventsPercent) {
<div class="visualization">
<div class="total-events">
{{ totalEventsAllowed() }}
</div>
<div class="display-bar">
<div class="file-size-percent" [style.width.%]="eventsPercent.fileSize"></div>
<div class="error-event" [style.width.%]="eventsPercent.errorEvents"></div>
<div class="uptime-event" [style.width.%]="eventsPercent.uptimeEvents"></div>
<div class="transaction-event" [style.width.%]="eventsPercent.transactionEvents"></div>
</div>
@if (eventsCountWithTotal(); as count) {
<div class="event-count" [class.event-count--left]="eventsPercent.total < 25"
[class.event-count--right]="eventsPercent.total > 75" style="left: {{
eventsPercent.total < 100 ? eventsPercent.total : 100
}}%">
{{ count.total }}
</div>
<div>
<ul>
<li>
<b class="error" i18n>Issue events:</b>
{{ count.eventCount }}
</li>
<li>
<b class="uptime" i18n>Uptime events:</b>
{{ count.uptimeCheckEventCount }}
</li>
<li>
<b class="performance" i18n>Performance events:</b>
{{ count.transactionEventCount }}
</li>
<li>
<b class="file-size" i18n>File storage:</b>
{{ count.fileSizeMb }} MB
</li>
<li i18n>Total
<a [routerLink]="[]" (click)="openEventInfoDialog()">events</a>
used in billing period: {{ count.total }}
</li>
</ul>
</div>
@if (subscriptionLoadingTimeout()) {
<div>
GlitchTip was unable to find a subscription for your account. If you
just signed up for a subscription on our payment platform, please
contact us at
<a href="mailto:{{ billingEmail }}">{{ billingEmail }}</a
>.
</div>
}
</mat-card-content>
</mat-card>
} @else {
@if (subscription(); as subscription) {
<mat-card>
<mat-card-content>
<div>
@if (
subscription.status === "active" ||
subscription.status === "trialing" ||
subscription.status === "past_due"
) {
@if (subscription.status === "active") {
<p class="mat-body-1">
@if (subscription.effectivePrice === 0) {
<ng-container i18n
>You have selected the free plan. Please consider making a
monthly
<a
href="https://liberapay.com/GlitchTip/donate"
target="_blank"
>donation</a
>
to help keep open source development sustainable. Thank
you for choosing GlitchTip!</ng-container
>
} @else {
<ng-container i18n
>This organization has an active subscription to
GlitchTip. Thank you!</ng-container
>
}
</p>
}
@if (subscription.status === "trialing") {
<p class="mat-body-1 no-bottom-margin" i18n
>This organization has a trial subscription to GlitchTip.</p
>
}
@if (subscription.status === "past_due") {
<p class="mat-body-1" i18n
>This organization has a subscription to GlitchTip, but the
payment is past due.</p
>
}
<ng-container>
<mat-card-subtitle i18n>Details</mat-card-subtitle>
<h3>{{ subscription.product.name }}</h3>
{{ subscription.product.description }}
@if (eventsPercent(); as eventsPercent) {
<div class="visualization">
<div class="total-events">
{{ totalEventsAllowed() }}
</div>
<div class="display-bar">
<div
class="file-size-percent"
[style.width.%]="eventsPercent.fileSize"
></div>
<div
class="error-event"
[style.width.%]="eventsPercent.errorEvents"
></div>
<div
class="uptime-event"
[style.width.%]="eventsPercent.uptimeEvents"
></div>
<div
class="transaction-event"
[style.width.%]="eventsPercent.transactionEvents"
></div>
</div>
@if (eventsCountWithTotal(); as count) {
<div
class="event-count"
[class.event-count--left]="eventsPercent.total < 25"
[class.event-count--right]="eventsPercent.total > 75"
style="left: {{
eventsPercent.total < 100 ? eventsPercent.total : 100
}}%"
>
{{ count.total }}
</div>
<div>
<ul>
<li>
<b class="error" i18n>Issue events:</b>
{{ count.eventCount }}
</li>
<li>
<b class="uptime" i18n>Uptime events:</b>
{{ count.uptimeCheckEventCount }}
</li>
<li>
<b class="performance" i18n>Performance events:</b>
{{ count.transactionEventCount }}
</li>
<li>
<b class="file-size" i18n>File storage:</b>
{{ count.fileSizeMb }} MB
</li>
<li i18n
>Total
<a [routerLink]="[]" (click)="openEventInfoDialog()"
>events</a
>
used in billing period: {{ count.total }}
</li>
</ul>
</div>
}
</div>
}
<ul>
<li class="mat-body-1" i18n
>Start date for this plan:
{{ subscription.startDate | date }}</li
>
<li class="mat-body-1" i18n
>Plan cost: {{ subscription.effectivePrice | currency }}</li
>
<li class="mat-body-1">
<ng-container i18n>Billing</ng-container>:
@if (
subscription.collectionMethod === "charge_automatically"
) {
<ng-container i18n>Automatic</ng-container>
}
@if (subscription.collectionMethod === "send_invoice") {
<ng-container i18n>Send invoice</ng-container>
}
</li>
<li class="mat-body-1" i18n
>Billing period:
{{ subscription.currentPeriodStart | date }} to
{{ subscription.currentPeriodEnd | date }}</li
>
</ul>
</ng-container>
<p class="mat-body-1" i18n
>If you have questions about your subscription, please contact
us at <a href="mailto:{{ billingEmail }}">{{ billingEmail }}</a
>.
</p>
<mat-error>{{ billingPortalLoadingError() }}</mat-error>
<gt-loading-button
color="primary"
(click)="manageSubscription()"
[loading]="billingPortalLoading()"
buttonText="Manage Subscription"
/>
} @else {
<p class="mat-body-1" i18n
>This organization does not have an active subscription to
GlitchTip. Pick a plan to get started.</p
>
}
</div>
}
<ul>
<li class="mat-body-1" i18n>Start date for this plan:
{{ subscription.startDate | date }}</li>
<li class="mat-body-1" i18n>Plan cost: {{ subscription.effectivePrice | currency }}</li>
<li class="mat-body-1">
<ng-container i18n>Billing</ng-container>: @if (
subscription.collectionMethod === "charge_automatically" ) {
<ng-container i18n>Automatic</ng-container>
} @if (subscription.collectionMethod === "send_invoice") {
<ng-container i18n>Send invoice</ng-container>
}
</li>
<li class="mat-body-1" i18n>Billing period: {{ subscription.currentPeriodStart | date }} to
{{ subscription.currentPeriodEnd | date }}</li>
</ul>
</ng-container>
<p class="mat-body-1" i18n>If you have questions about your subscription, please contact us at
<a href="mailto:{{ billingEmail }}">{{ billingEmail }}</a>.
</p>
<mat-error>{{ billingPortalLoadingError() }}</mat-error>
<gt-loading-button color="primary" (click)="manageSubscription()" [loading]="billingPortalLoading()" buttonText="Manage Subscription" />
} @else {
<p class="mat-body-1" i18n>This organization does not have an active subscription to GlitchTip.
Pick a plan to get started.</p>
}
</div>
</mat-card-content>
</mat-card>
@if (promptForProject()) {
<mat-card>
<mat-card-content>
<p i18n>Now that you have an organization and a subscription, create a
project.</p>
@if (activeOrganizationSlug(); as slug) {
<a mat-flat-button [routerLink]="['/', slug, 'settings', 'projects', 'new']" color="primary" i18n>Create New
Project</a>
</mat-card-content>
</mat-card>
@if (promptForProject()) {
<mat-card>
<mat-card-content>
<p i18n
>Now that you have an organization and a subscription, create a
project.</p
>
@if (activeOrganizationSlug(); as slug) {
<a
mat-flat-button
[routerLink]="['/', slug, 'settings', 'projects', 'new']"
color="primary"
i18n
>Create New Project</a
>
}
</mat-card-content>
</mat-card>
}
</mat-card-content>
</mat-card>
} } @if ( (subscriptionLoading() === false && (subscription() ) === null) ||
(subscription())!.status === "canceled" ) {
<gt-payment></gt-payment>
} } @else { @if ( (subscriptionLoading()) || (subscriptionLoadingTimeout()) )
{
<mat-card>
<mat-card-header></mat-card-header>
<mat-card-content>
@if (subscriptionLoading()) {
<div class="spinner-container">
<mat-spinner diameter="36"></mat-spinner>
</div>
} @if (subscriptionLoadingTimeout()) {
<div>
GlitchTip was unable to find a subscription for your account. If you
just signed up for a subscription on our payment platform, please
contact us at
<a href="mailto:{{ billingEmail }}">{{ billingEmail }}</a>.
</div>
}
</mat-card-content>
</mat-card>
} }
}
@if (
(subscriptionLoading() === false && subscription() === null) ||
subscription()!.status === "canceled"
) {
<gt-payment></gt-payment>
}
}
</section>
@@ -1,8 +1,14 @@
import { Component, ChangeDetectionStrategy, OnDestroy, computed, inject } from "@angular/core";
import { ActivatedRoute, RouterLink } from "@angular/router";
import {
Component,
ChangeDetectionStrategy,
OnDestroy,
computed,
inject,
input,
OnInit,
} from "@angular/core";
import { RouterLink } from "@angular/router";
import { MatDialog, MatDialogModule } from "@angular/material/dialog";
import { combineLatest, Subscription } from "rxjs";
import { map, filter, take } from "rxjs/operators";
import { EventInfoComponent } from "src/app/shared/event-info/event-info.component";
import { environment } from "../../../environments/environment";
import { SubscriptionsService } from "src/app/api/subscriptions/subscriptions.service";
@@ -41,11 +47,16 @@ interface Percentages {
DatePipe,
],
})
export class SubscriptionComponent implements OnDestroy {
export class SubscriptionComponent implements OnDestroy, OnInit {
private service = inject(SubscriptionsService);
private route = inject(ActivatedRoute);
dialog = inject(MatDialog);
private orgService = inject(OrganizationsService);
dialog = inject(MatDialog);
orgSlug = input.required<string>({ alias: "org-slug" });
sessionId = input<string>("", { alias: "session_id" });
billingPortalRedirect = input<string>("", {
alias: "billing_portal_redirect",
});
fromStripe = this.service.fromStripe;
subscription = this.service.subscription;
@@ -55,8 +66,8 @@ export class SubscriptionComponent implements OnDestroy {
totalEventsAllowed = this.service.totalEventsAllowed;
activeOrganization = this.orgService.activeOrganization;
activeOrganizationSlug = this.orgService.activeOrganizationSlug;
billingPortalLoading = this.service.billingPortalLoading
billingPortalLoadingError = this.service.billingPortalLoadingError
billingPortalLoading = this.service.billingPortalLoading;
billingPortalLoadingError = this.service.billingPortalLoadingError;
promptForProject = computed(() => {
const status = this.orgService.activeOrganizationLoaded();
@@ -73,7 +84,6 @@ export class SubscriptionComponent implements OnDestroy {
return false;
}
});
routerSubscription: Subscription;
billingEmail = environment.billingEmail;
eventsPercent = computed<Percentages>(() => {
const eventsAllowed = this.totalEventsAllowed();
@@ -88,32 +98,16 @@ export class SubscriptionComponent implements OnDestroy {
};
});
constructor() {
this.routerSubscription = combineLatest([
this.route.params,
this.route.queryParams,
])
.pipe(
map(([params, queryParams]) => {
const slug = params["org-slug"] as string;
const sessionId = queryParams["session_id"];
const redirectFromBillingPortal =
queryParams["billing_portal_redirect"];
return { slug, sessionId, redirectFromBillingPortal };
}),
filter((routerData) => !!routerData.slug),
take(1)
)
.subscribe((routerData) => {
if (routerData.sessionId) {
// this.service.retrieveUntilSubscriptionOrTimeout(routerData.slug);
} else {
this.service.subscriptionResource.reload();
}
if (routerData.redirectFromBillingPortal) {
this.orgService.repeatRefreshOrgDetail();
}
});
ngOnInit(): void {
this.orgService.activeOrganizationResource.reload();
this.service.retrieveSubscriptionData(this.orgSlug());
if (this.sessionId()) {
this.service.refreshUntilSubscriptionOrTimeout();
}
if (this.billingPortalRedirect()) {
this.orgService.repeatRefreshOrgDetail();
}
}
openEventInfoDialog() {
@@ -127,7 +121,6 @@ export class SubscriptionComponent implements OnDestroy {
}
ngOnDestroy() {
this.routerSubscription.unsubscribe();
this.service.clearState();
}
}