Skip to content

Commit 624500b

Browse files
authored
feat: invoice dashboard page overview (#83)
1 parent 4f38c02 commit 624500b

12 files changed

Lines changed: 462 additions & 4 deletions

File tree

apps/api/src/routes/invoices.routes.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { PriceModule } from '../modules/Price';
2121
import { ProductModule } from '../modules/Product';
2222
import { InvoiceItemModule } from '../modules/InvoiceItem';
2323
import { InvoiceModule } from '../modules/Invoice';
24+
import { SubscriptionModule } from '../modules/Subscription';
2425
import { PaymentIntentModule } from '../modules/PaymentIntent';
2526
import { ChargeModule } from '../modules/Charge';
2627

@@ -63,13 +64,26 @@ const invoiceModule = new InvoiceModule(
6364
chargeModule,
6465
priceModule
6566
);
67+
const subscriptionModule = new SubscriptionModule(
68+
db,
69+
eventService,
70+
customerModule,
71+
priceModule,
72+
invoiceModule
73+
);
6674

6775
RegisterExpansions('invoice', {
6876
customer: {
6977
sourcePath: 'customer',
7078
targetObject: 'customer',
7179
BatchLoad: (ids, ctx) => customerModule.BatchGet(ids, ctx.platformAccount),
7280
},
81+
subscription: {
82+
sourcePath: 'parent.subscription_details.subscription',
83+
targetObject: 'subscription',
84+
BatchLoad: (ids, ctx) =>
85+
subscriptionModule.BatchGet(ids, ctx.platformAccount),
86+
},
7387
});
7488

7589
/**

apps/api/src/utils/Expand.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ export interface ExpandContext {
2121
}
2222

2323
export interface ExpansionField {
24-
/** Field on the parent object that holds the id (and where we write the hydrated object). */
24+
/**
25+
* Field on the parent object that holds the id (and where we write the hydrated
26+
* object). Dot-separated paths are supported for nested ids
27+
* (e.g. `parent.subscription_details.subscription`).
28+
*/
2529
sourcePath: string;
2630
/** `object` string of the linked resource. Used to recurse via the registry. */
2731
targetObject: string;
@@ -226,7 +230,7 @@ async function ExpandObjects(
226230
if (field.embeddedList) {
227231
const nested: AnyObject[] = [];
228232
for (const item of items) {
229-
const value = item[field.sourcePath];
233+
const value = GetByPath(item, field.sourcePath);
230234
if (value && typeof value === 'object') {
231235
nested.push(value as AnyObject);
232236
}
@@ -239,7 +243,7 @@ async function ExpandObjects(
239243

240244
const idToParents = new Map<string, AnyObject[]>();
241245
for (const item of items) {
242-
const value = item[field.sourcePath];
246+
const value = GetByPath(item, field.sourcePath);
243247
if (typeof value !== 'string') continue;
244248
const parents = idToParents.get(value) ?? [];
245249
parents.push(item);
@@ -263,7 +267,7 @@ async function ExpandObjects(
263267
for (const [id, parents] of idToParents) {
264268
const expanded = ctx.cache.get(CacheKey(field.targetObject, id)) ?? null;
265269
for (const parent of parents) {
266-
parent[field.sourcePath] = expanded;
270+
SetByPath(parent, field.sourcePath, expanded);
267271
}
268272
if (expanded && tails.length > 0) {
269273
nextItems.push(expanded as AnyObject);
@@ -276,6 +280,35 @@ async function ExpandObjects(
276280
}
277281
}
278282

283+
function GetByPath(obj: AnyObject, path: string): unknown {
284+
const parts = path.split('.');
285+
let current: unknown = obj;
286+
for (const part of parts) {
287+
if (
288+
current === null ||
289+
current === undefined ||
290+
typeof current !== 'object'
291+
) {
292+
return undefined;
293+
}
294+
current = (current as AnyObject)[part];
295+
}
296+
return current;
297+
}
298+
299+
function SetByPath(obj: AnyObject, path: string, value: unknown): void {
300+
const parts = path.split('.');
301+
let current: AnyObject = obj;
302+
for (let i = 0; i < parts.length - 1; i++) {
303+
const next = current[parts[i]];
304+
if (next === null || next === undefined || typeof next !== 'object') {
305+
return;
306+
}
307+
current = next as AnyObject;
308+
}
309+
current[parts[parts.length - 1]] = value;
310+
}
311+
279312
function CacheKey(objectType: string, id: string): string {
280313
return `${objectType}:${id}`;
281314
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './charge.service';
66
export * from './customer.service';
77
export * from './config.service';
88
export * from './external-wallet.service';
9+
export * from './invoice.service';
910
export * from './payment-intent.service';
1011
export * from './payment-link.service';
1112
export * from './person.service';
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Injectable, inject, signal, WritableSignal } from '@angular/core';
2+
import { ApiService } from '../../core';
3+
import { Invoice } from '@zoneless/shared-types';
4+
import {
5+
CreateInvoiceInput,
6+
FinalizeInvoiceInput,
7+
PayInvoiceInput,
8+
UpdateInvoiceInput,
9+
VoidInvoiceInput,
10+
} from '@zoneless/shared-schemas';
11+
12+
@Injectable({
13+
providedIn: 'root',
14+
})
15+
export class InvoiceService {
16+
private readonly api = inject(ApiService);
17+
18+
loading: WritableSignal<boolean> = signal(false);
19+
20+
async CreateInvoice(data: CreateInvoiceInput): Promise<Invoice> {
21+
this.loading.set(true);
22+
try {
23+
return await this.api.Call<Invoice>('POST', `invoices`, data);
24+
} finally {
25+
this.loading.set(false);
26+
}
27+
}
28+
29+
async GetInvoice(
30+
invoiceId: string,
31+
expand: string[] = ['customer']
32+
): Promise<Invoice> {
33+
this.loading.set(true);
34+
try {
35+
const expandQuery =
36+
expand.length > 0 ? `?expand=${expand.join(',')}` : '';
37+
return await this.api.Call<Invoice>(
38+
'GET',
39+
`invoices/${invoiceId}${expandQuery}`
40+
);
41+
} finally {
42+
this.loading.set(false);
43+
}
44+
}
45+
46+
async UpdateInvoice(
47+
invoiceId: string,
48+
data: UpdateInvoiceInput
49+
): Promise<Invoice> {
50+
this.loading.set(true);
51+
try {
52+
return await this.api.Call<Invoice>(
53+
'POST',
54+
`invoices/${invoiceId}`,
55+
data
56+
);
57+
} finally {
58+
this.loading.set(false);
59+
}
60+
}
61+
62+
async DeleteInvoice(invoiceId: string): Promise<void> {
63+
this.loading.set(true);
64+
try {
65+
await this.api.Call<void>('DELETE', `invoices/${invoiceId}`);
66+
} finally {
67+
this.loading.set(false);
68+
}
69+
}
70+
71+
async FinalizeInvoice(
72+
invoiceId: string,
73+
data: FinalizeInvoiceInput = {}
74+
): Promise<Invoice> {
75+
this.loading.set(true);
76+
try {
77+
return await this.api.Call<Invoice>(
78+
'POST',
79+
`invoices/${invoiceId}/finalize`,
80+
data
81+
);
82+
} finally {
83+
this.loading.set(false);
84+
}
85+
}
86+
87+
async PayInvoice(
88+
invoiceId: string,
89+
data: PayInvoiceInput = {}
90+
): Promise<Invoice> {
91+
this.loading.set(true);
92+
try {
93+
return await this.api.Call<Invoice>(
94+
'POST',
95+
`invoices/${invoiceId}/pay`,
96+
data
97+
);
98+
} finally {
99+
this.loading.set(false);
100+
}
101+
}
102+
103+
async VoidInvoice(
104+
invoiceId: string,
105+
data: VoidInvoiceInput = {}
106+
): Promise<Invoice> {
107+
this.loading.set(true);
108+
try {
109+
return await this.api.Call<Invoice>(
110+
'POST',
111+
`invoices/${invoiceId}/void`,
112+
data
113+
);
114+
} finally {
115+
this.loading.set(false);
116+
}
117+
}
118+
119+
async MarkInvoiceUncollectible(invoiceId: string): Promise<Invoice> {
120+
this.loading.set(true);
121+
try {
122+
return await this.api.Call<Invoice>(
123+
'POST',
124+
`invoices/${invoiceId}/mark_uncollectible`
125+
);
126+
} finally {
127+
this.loading.set(false);
128+
}
129+
}
130+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ export const accountRoutes: Routes = [
2727
(m) => m.subscriptionRoutes
2828
),
2929
},
30+
{
31+
path: 'invoices',
32+
canActivate: [platformOnlyGuard],
33+
loadChildren: () =>
34+
import('./invoices/invoices.routes').then((m) => m.invoiceRoutes),
35+
},
3036
{
3137
path: 'connected-accounts',
3238
canActivate: [platformOnlyGuard],
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 invoiceRoutes: Routes = [
4+
{
5+
path: '',
6+
children: [
7+
{
8+
path: '',
9+
loadComponent: () =>
10+
import('./views/invoice-list/invoice-list.component').then(
11+
(m) => m.InvoiceListComponent
12+
),
13+
},
14+
],
15+
},
16+
];
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type {
2+
Customer,
3+
Invoice,
4+
Price,
5+
Subscription,
6+
} from '@zoneless/shared-types';
7+
import { FormatIntervalLabel } from '../../products/util/price-display';
8+
9+
export function FormatInvoiceNumber(invoice: Invoice): string {
10+
return invoice.number ?? '—';
11+
}
12+
13+
export function FormatInvoiceCustomerName(invoice: Invoice): string {
14+
if (invoice.customer_name) return invoice.customer_name;
15+
16+
const customer = invoice.customer;
17+
if (!customer || typeof customer === 'string') return '—';
18+
return customer.name ?? '—';
19+
}
20+
21+
export function FormatInvoiceCustomerEmail(invoice: Invoice): string {
22+
if (invoice.customer_email) return invoice.customer_email;
23+
24+
const customer = invoice.customer;
25+
if (!customer) return '—';
26+
if (typeof customer === 'string') return customer;
27+
return (customer as Customer).email ?? customer.id;
28+
}
29+
30+
/**
31+
* Billing frequency for subscription-backed invoices (e.g. Monthly, Yearly).
32+
* One-off invoices return an em dash.
33+
*/
34+
export function FormatInvoiceFrequency(invoice: Invoice): string {
35+
const parent = invoice.parent;
36+
if (!parent || parent.type !== 'subscription_details') return '—';
37+
38+
const subscription = parent.subscription_details?.subscription;
39+
if (!subscription || typeof subscription === 'string') return '—';
40+
41+
const price = (subscription as Subscription).items?.data?.[0]?.price;
42+
if (!price || typeof price === 'string') return '—';
43+
44+
const interval = (price as Price).recurring?.interval;
45+
if (!interval) return '—';
46+
47+
return FormatIntervalLabel(interval);
48+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<div class="header-wrapper">
2+
<h1>Invoices</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 invoice</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]="invoicesStatusTab() === 'all'"
20+
(click)="SetInvoicesStatusTab('all')"
21+
(keydown.enter)="SetInvoicesStatusTab('all')"
22+
(keydown.space)="SetInvoicesStatusTab('all')"
23+
>
24+
<span>All invoices</span>
25+
</div>
26+
<div
27+
class="field-pill-option"
28+
role="button"
29+
tabindex="0"
30+
[class.field-pill-option-selected]="invoicesStatusTab() === 'draft'"
31+
(click)="SetInvoicesStatusTab('draft')"
32+
(keydown.enter)="SetInvoicesStatusTab('draft')"
33+
(keydown.space)="SetInvoicesStatusTab('draft')"
34+
>
35+
<span>Draft</span>
36+
</div>
37+
<div
38+
class="field-pill-option"
39+
role="button"
40+
tabindex="0"
41+
[class.field-pill-option-selected]="invoicesStatusTab() === 'open'"
42+
(click)="SetInvoicesStatusTab('open')"
43+
(keydown.enter)="SetInvoicesStatusTab('open')"
44+
(keydown.space)="SetInvoicesStatusTab('open')"
45+
>
46+
<span>Open</span>
47+
</div>
48+
<div
49+
class="field-pill-option"
50+
role="button"
51+
tabindex="0"
52+
[class.field-pill-option-selected]="invoicesStatusTab() === 'paid'"
53+
(click)="SetInvoicesStatusTab('paid')"
54+
(keydown.enter)="SetInvoicesStatusTab('paid')"
55+
(keydown.space)="SetInvoicesStatusTab('paid')"
56+
>
57+
<span>Paid</span>
58+
</div>
59+
</div>
60+
</div>
61+
62+
<app-paginated-list
63+
endpoint="invoices"
64+
[columns]="invoiceColumns"
65+
[limit]="10"
66+
[queryParams]="invoicesQueryParams()"
67+
[expand]="invoicesExpand()"
68+
></app-paginated-list>

0 commit comments

Comments
 (0)