Skip to content

Commit 4f38c02

Browse files
authored
feat: subscriptions dashboard overview (#82)
1 parent 33a8dd5 commit 4f38c02

13 files changed

Lines changed: 467 additions & 1 deletion

File tree

apps/api/src/routes/subscriptions.routes.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,20 @@ RegisterExpansions('subscription', {
8383
targetObject: 'invoice',
8484
BatchLoad: (ids, ctx) => invoiceModule.BatchGet(ids, ctx.platformAccount),
8585
},
86+
items: {
87+
sourcePath: 'items',
88+
targetObject: 'subscription_item',
89+
embeddedList: true,
90+
BatchLoad: async () => new Map(),
91+
},
92+
});
93+
94+
RegisterExpansions('subscription_item', {
95+
price: {
96+
sourcePath: 'price',
97+
targetObject: 'price',
98+
BatchLoad: (ids, ctx) => priceModule.BatchGet(ids, ctx.platformAccount),
99+
},
86100
});
87101

88102
/**

apps/api/src/utils/Expand.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import * as express from 'express';
1212
import { AppError } from './AppError';
1313
import { ERRORS } from '../utils/Errors';
1414

15-
const MAX_EXPAND_DEPTH = 4;
15+
/** Allows list paths such as `data.items.data.price.product`. */
16+
const MAX_EXPAND_DEPTH = 5;
1617

1718
export interface ExpandContext {
1819
platformAccount: string;
@@ -24,6 +25,11 @@ export interface ExpansionField {
2425
sourcePath: string;
2526
/** `object` string of the linked resource. Used to recurse via the registry. */
2627
targetObject: string;
28+
/**
29+
* When true, the field is already an embedded list object (`{ object: 'list', data }`)
30+
* on the parent. Expand walks into it instead of batch-loading by id.
31+
*/
32+
embeddedList?: boolean;
2733
/** Batch-load ids → records, scoped to the requesting platform. */
2834
BatchLoad: (
2935
ids: string[],
@@ -113,6 +119,9 @@ function ValidateExpandPaths(
113119
);
114120
}
115121
cursor = field.targetObject;
122+
if (field.embeddedList) {
123+
cursorIsList = true;
124+
}
116125
}
117126
}
118127
}
@@ -214,6 +223,20 @@ async function ExpandObjects(
214223
const field = GetExpansion(sampleObject, head);
215224
if (!field) continue;
216225

226+
if (field.embeddedList) {
227+
const nested: AnyObject[] = [];
228+
for (const item of items) {
229+
const value = item[field.sourcePath];
230+
if (value && typeof value === 'object') {
231+
nested.push(value as AnyObject);
232+
}
233+
}
234+
if (tails.length > 0 && nested.length > 0) {
235+
await ExpandObjects(nested, tails, ctx);
236+
}
237+
continue;
238+
}
239+
217240
const idToParents = new Map<string, AnyObject[]>();
218241
for (const item of items) {
219242
const value = item[field.sourcePath];

apps/web/src/app/data/services/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export * from './price.service';
1313
export * from './product.service';
1414
export * from './reporting.service';
1515
export * from './setup.service';
16+
export * from './subscription.service';
1617
export * from './topup.service';
1718
export * from './transaction.service';
1819
export * from './webhook-endpoint.service';
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Injectable, inject, signal, WritableSignal } from '@angular/core';
2+
import { ApiService } from '../../core';
3+
import { Subscription } from '@zoneless/shared-types';
4+
import {
5+
CancelSubscriptionInput,
6+
CreateSubscriptionInput,
7+
ResumeSubscriptionInput,
8+
UpdateSubscriptionInput,
9+
} from '@zoneless/shared-schemas';
10+
11+
@Injectable({
12+
providedIn: 'root',
13+
})
14+
export class SubscriptionService {
15+
private readonly api = inject(ApiService);
16+
17+
loading: WritableSignal<boolean> = signal(false);
18+
19+
async CreateSubscription(
20+
data: CreateSubscriptionInput
21+
): Promise<Subscription> {
22+
this.loading.set(true);
23+
try {
24+
return await this.api.Call<Subscription>('POST', `subscriptions`, data);
25+
} finally {
26+
this.loading.set(false);
27+
}
28+
}
29+
30+
async GetSubscription(
31+
subscriptionId: string,
32+
expand: string[] = ['customer']
33+
): Promise<Subscription> {
34+
this.loading.set(true);
35+
try {
36+
const expandQuery =
37+
expand.length > 0 ? `?expand=${expand.join(',')}` : '';
38+
return await this.api.Call<Subscription>(
39+
'GET',
40+
`subscriptions/${subscriptionId}${expandQuery}`
41+
);
42+
} finally {
43+
this.loading.set(false);
44+
}
45+
}
46+
47+
async UpdateSubscription(
48+
subscriptionId: string,
49+
data: UpdateSubscriptionInput
50+
): Promise<Subscription> {
51+
this.loading.set(true);
52+
try {
53+
return await this.api.Call<Subscription>(
54+
'POST',
55+
`subscriptions/${subscriptionId}`,
56+
data
57+
);
58+
} finally {
59+
this.loading.set(false);
60+
}
61+
}
62+
63+
async CancelSubscription(
64+
subscriptionId: string,
65+
data: CancelSubscriptionInput = {}
66+
): Promise<Subscription> {
67+
this.loading.set(true);
68+
try {
69+
return await this.api.Call<Subscription>(
70+
'DELETE',
71+
`subscriptions/${subscriptionId}`,
72+
data
73+
);
74+
} finally {
75+
this.loading.set(false);
76+
}
77+
}
78+
79+
async ResumeSubscription(
80+
subscriptionId: string,
81+
data: ResumeSubscriptionInput = {}
82+
): Promise<Subscription> {
83+
this.loading.set(true);
84+
try {
85+
return await this.api.Call<Subscription>(
86+
'POST',
87+
`subscriptions/${subscriptionId}/resume`,
88+
data
89+
);
90+
} finally {
91+
this.loading.set(false);
92+
}
93+
}
94+
}

apps/web/src/app/features/account/account.routes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ export const accountRoutes: Routes = [
1919
loadChildren: () =>
2020
import('./customers/customers.routes').then((m) => m.customerRoutes),
2121
},
22+
{
23+
path: 'subscriptions',
24+
canActivate: [platformOnlyGuard],
25+
loadChildren: () =>
26+
import('./subscriptions/subscriptions.routes').then(
27+
(m) => m.subscriptionRoutes
28+
),
29+
},
2230
{
2331
path: 'connected-accounts',
2432
canActivate: [platformOnlyGuard],

apps/web/src/app/features/account/nav/full-nav.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const FULL_NAV: SideMenuGroup[] = [
77
{ title: 'Balances', icon: 'account_balance.svg', id: 'balance' },
88
{ title: 'Transactions', icon: 'autorenew.svg', id: 'payments' },
99
{ title: 'Customers', icon: 'person.svg', id: 'customers' },
10+
{ title: 'Subscriptions', icon: 'refresh.svg', id: 'subscriptions' },
1011
{ title: 'Product Catalog', icon: 'package.svg', id: 'products' },
1112
{ title: 'Payment Links', icon: 'link.svg', id: 'payment-links' },
1213
],
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Routes } from '@angular/router';
2+
3+
export const subscriptionRoutes: Routes = [
4+
{
5+
path: '',
6+
children: [
7+
{
8+
path: '',
9+
loadComponent: () =>
10+
import('./views/subscription-list/subscription-list.component').then(
11+
(m) => m.SubscriptionListComponent
12+
),
13+
},
14+
],
15+
},
16+
];
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type {
2+
Customer,
3+
Price,
4+
Product,
5+
Subscription,
6+
} from '@zoneless/shared-types';
7+
8+
/**
9+
* Display status for the subscriptions list.
10+
* Mirrors Stripe: active subscriptions set to cancel show "Cancels {date}".
11+
*/
12+
export function GetSubscriptionListStatus(subscription: Subscription): string {
13+
if (
14+
subscription.cancel_at_period_end &&
15+
(subscription.status === 'active' || subscription.status === 'trialing')
16+
) {
17+
const cancelAt =
18+
subscription.cancel_at ??
19+
subscription.items?.data?.[0]?.current_period_end ??
20+
null;
21+
if (cancelAt) {
22+
return `Cancels ${FormatShortDate(cancelAt)}`;
23+
}
24+
}
25+
return subscription.status;
26+
}
27+
28+
export function FormatSubscriptionCustomerEmail(
29+
subscription: Subscription
30+
): string {
31+
const customer = subscription.customer;
32+
if (!customer) return '—';
33+
if (typeof customer === 'string') return customer;
34+
return customer.email ?? customer.id;
35+
}
36+
37+
export function FormatSubscriptionCustomerName(
38+
subscription: Subscription
39+
): string {
40+
const customer = subscription.customer;
41+
if (!customer || typeof customer === 'string') return '—';
42+
return customer.name ?? '—';
43+
}
44+
45+
export function FormatSubscriptionCustomerDescription(
46+
subscription: Subscription
47+
): string {
48+
const customer = subscription.customer;
49+
if (!customer || typeof customer === 'string') return '—';
50+
return (customer as Customer).description ?? '—';
51+
}
52+
53+
export function FormatSubscriptionCollectionMethod(
54+
subscription: Subscription
55+
): string {
56+
return subscription.collection_method === 'charge_automatically'
57+
? 'Automatic'
58+
: 'Send invoice';
59+
}
60+
61+
export function FormatSubscriptionProduct(subscription: Subscription): string {
62+
const item = subscription.items?.data?.[0];
63+
if (!item) return '—';
64+
65+
const price = item.price;
66+
if (typeof price === 'string') return price;
67+
68+
const expandedPrice = price as Price;
69+
const product = expandedPrice.product;
70+
if (product && typeof product === 'object') {
71+
return (product as Product).name || expandedPrice.id;
72+
}
73+
if (expandedPrice.nickname) return expandedPrice.nickname;
74+
if (typeof product === 'string') return product;
75+
return expandedPrice.id;
76+
}
77+
78+
function FormatShortDate(unixSeconds: number): string {
79+
return new Date(unixSeconds * 1000).toLocaleDateString('en-GB', {
80+
day: 'numeric',
81+
month: 'short',
82+
});
83+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<div class="header-wrapper">
2+
<h1>Subscriptions</h1>
3+
<button
4+
type="button"
5+
class="action-button action-button-dainty action-button-disabled"
6+
disabled
7+
>
8+
<img class="button-icon" src="assets/icons/add.svg" alt="" />
9+
<span>Create subscription</span>
10+
</button>
11+
</div>
12+
13+
<div class="field">
14+
<div class="field-pill">
15+
<div
16+
class="field-pill-option"
17+
role="button"
18+
tabindex="0"
19+
[class.field-pill-option-selected]="subscriptionsStatusTab() === 'active'"
20+
(click)="SetSubscriptionsStatusTab('active')"
21+
(keydown.enter)="SetSubscriptionsStatusTab('active')"
22+
(keydown.space)="SetSubscriptionsStatusTab('active')"
23+
>
24+
<span>Active</span>
25+
</div>
26+
<div
27+
class="field-pill-option"
28+
role="button"
29+
tabindex="0"
30+
[class.field-pill-option-selected]="subscriptionsStatusTab() === 'paused'"
31+
(click)="SetSubscriptionsStatusTab('paused')"
32+
(keydown.enter)="SetSubscriptionsStatusTab('paused')"
33+
(keydown.space)="SetSubscriptionsStatusTab('paused')"
34+
>
35+
<span>Paused</span>
36+
</div>
37+
<div
38+
class="field-pill-option"
39+
role="button"
40+
tabindex="0"
41+
[class.field-pill-option-selected]="
42+
subscriptionsStatusTab() === 'canceled'
43+
"
44+
(click)="SetSubscriptionsStatusTab('canceled')"
45+
(keydown.enter)="SetSubscriptionsStatusTab('canceled')"
46+
(keydown.space)="SetSubscriptionsStatusTab('canceled')"
47+
>
48+
<span>Canceled</span>
49+
</div>
50+
<div
51+
class="field-pill-option"
52+
role="button"
53+
tabindex="0"
54+
[class.field-pill-option-selected]="subscriptionsStatusTab() === 'all'"
55+
(click)="SetSubscriptionsStatusTab('all')"
56+
(keydown.enter)="SetSubscriptionsStatusTab('all')"
57+
(keydown.space)="SetSubscriptionsStatusTab('all')"
58+
>
59+
<span>All</span>
60+
</div>
61+
</div>
62+
</div>
63+
64+
<app-paginated-list
65+
endpoint="subscriptions"
66+
[columns]="subscriptionColumns"
67+
[limit]="10"
68+
[queryParams]="subscriptionsQueryParams()"
69+
[expand]="subscriptionsExpand()"
70+
></app-paginated-list>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@use '../../../../../styles/account.scss' as *;
2+
@use '../../../../../styles/buttons.scss' as *;
3+
@use '../../../../../styles/forms.scss' as *;

0 commit comments

Comments
 (0)