Skip to content
Merged
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
14 changes: 14 additions & 0 deletions apps/api/src/routes/invoices.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { PriceModule } from '../modules/Price';
import { ProductModule } from '../modules/Product';
import { InvoiceItemModule } from '../modules/InvoiceItem';
import { InvoiceModule } from '../modules/Invoice';
import { SubscriptionModule } from '../modules/Subscription';
import { PaymentIntentModule } from '../modules/PaymentIntent';
import { ChargeModule } from '../modules/Charge';

Expand Down Expand Up @@ -63,13 +64,26 @@ const invoiceModule = new InvoiceModule(
chargeModule,
priceModule
);
const subscriptionModule = new SubscriptionModule(
db,
eventService,
customerModule,
priceModule,
invoiceModule
);

RegisterExpansions('invoice', {
customer: {
sourcePath: 'customer',
targetObject: 'customer',
BatchLoad: (ids, ctx) => customerModule.BatchGet(ids, ctx.platformAccount),
},
subscription: {
sourcePath: 'parent.subscription_details.subscription',
targetObject: 'subscription',
BatchLoad: (ids, ctx) =>
subscriptionModule.BatchGet(ids, ctx.platformAccount),
},
});

/**
Expand Down
41 changes: 37 additions & 4 deletions apps/api/src/utils/Expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ export interface ExpandContext {
}

export interface ExpansionField {
/** Field on the parent object that holds the id (and where we write the hydrated object). */
/**
* Field on the parent object that holds the id (and where we write the hydrated
* object). Dot-separated paths are supported for nested ids
* (e.g. `parent.subscription_details.subscription`).
*/
sourcePath: string;
/** `object` string of the linked resource. Used to recurse via the registry. */
targetObject: string;
Expand Down Expand Up @@ -226,7 +230,7 @@ async function ExpandObjects(
if (field.embeddedList) {
const nested: AnyObject[] = [];
for (const item of items) {
const value = item[field.sourcePath];
const value = GetByPath(item, field.sourcePath);
if (value && typeof value === 'object') {
nested.push(value as AnyObject);
}
Expand All @@ -239,7 +243,7 @@ async function ExpandObjects(

const idToParents = new Map<string, AnyObject[]>();
for (const item of items) {
const value = item[field.sourcePath];
const value = GetByPath(item, field.sourcePath);
if (typeof value !== 'string') continue;
const parents = idToParents.get(value) ?? [];
parents.push(item);
Expand All @@ -263,7 +267,7 @@ async function ExpandObjects(
for (const [id, parents] of idToParents) {
const expanded = ctx.cache.get(CacheKey(field.targetObject, id)) ?? null;
for (const parent of parents) {
parent[field.sourcePath] = expanded;
SetByPath(parent, field.sourcePath, expanded);
}
if (expanded && tails.length > 0) {
nextItems.push(expanded as AnyObject);
Expand All @@ -276,6 +280,35 @@ async function ExpandObjects(
}
}

function GetByPath(obj: AnyObject, path: string): unknown {
const parts = path.split('.');
let current: unknown = obj;
for (const part of parts) {
if (
current === null ||
current === undefined ||
typeof current !== 'object'
) {
return undefined;
}
current = (current as AnyObject)[part];
}
return current;
}

function SetByPath(obj: AnyObject, path: string, value: unknown): void {
const parts = path.split('.');
let current: AnyObject = obj;
for (let i = 0; i < parts.length - 1; i++) {
const next = current[parts[i]];
if (next === null || next === undefined || typeof next !== 'object') {
return;
}
current = next as AnyObject;
}
current[parts[parts.length - 1]] = value;
}

function CacheKey(objectType: string, id: string): string {
return `${objectType}:${id}`;
}
1 change: 1 addition & 0 deletions apps/web/src/app/data/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './charge.service';
export * from './customer.service';
export * from './config.service';
export * from './external-wallet.service';
export * from './invoice.service';
export * from './payment-intent.service';
export * from './payment-link.service';
export * from './person.service';
Expand Down
130 changes: 130 additions & 0 deletions apps/web/src/app/data/services/invoice.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Injectable, inject, signal, WritableSignal } from '@angular/core';
import { ApiService } from '../../core';
import { Invoice } from '@zoneless/shared-types';
import {
CreateInvoiceInput,
FinalizeInvoiceInput,
PayInvoiceInput,
UpdateInvoiceInput,
VoidInvoiceInput,
} from '@zoneless/shared-schemas';

@Injectable({
providedIn: 'root',
})
export class InvoiceService {
private readonly api = inject(ApiService);

loading: WritableSignal<boolean> = signal(false);

async CreateInvoice(data: CreateInvoiceInput): Promise<Invoice> {
this.loading.set(true);
try {
return await this.api.Call<Invoice>('POST', `invoices`, data);
} finally {
this.loading.set(false);
}
}

async GetInvoice(
invoiceId: string,
expand: string[] = ['customer']
): Promise<Invoice> {
this.loading.set(true);
try {
const expandQuery =
expand.length > 0 ? `?expand=${expand.join(',')}` : '';
return await this.api.Call<Invoice>(
'GET',
`invoices/${invoiceId}${expandQuery}`
);
} finally {
this.loading.set(false);
}
}

async UpdateInvoice(
invoiceId: string,
data: UpdateInvoiceInput
): Promise<Invoice> {
this.loading.set(true);
try {
return await this.api.Call<Invoice>(
'POST',
`invoices/${invoiceId}`,
data
);
} finally {
this.loading.set(false);
}
}

async DeleteInvoice(invoiceId: string): Promise<void> {
this.loading.set(true);
try {
await this.api.Call<void>('DELETE', `invoices/${invoiceId}`);
} finally {
this.loading.set(false);
}
}

async FinalizeInvoice(
invoiceId: string,
data: FinalizeInvoiceInput = {}
): Promise<Invoice> {
this.loading.set(true);
try {
return await this.api.Call<Invoice>(
'POST',
`invoices/${invoiceId}/finalize`,
data
);
} finally {
this.loading.set(false);
}
}

async PayInvoice(
invoiceId: string,
data: PayInvoiceInput = {}
): Promise<Invoice> {
this.loading.set(true);
try {
return await this.api.Call<Invoice>(
'POST',
`invoices/${invoiceId}/pay`,
data
);
} finally {
this.loading.set(false);
}
}

async VoidInvoice(
invoiceId: string,
data: VoidInvoiceInput = {}
): Promise<Invoice> {
this.loading.set(true);
try {
return await this.api.Call<Invoice>(
'POST',
`invoices/${invoiceId}/void`,
data
);
} finally {
this.loading.set(false);
}
}

async MarkInvoiceUncollectible(invoiceId: string): Promise<Invoice> {
this.loading.set(true);
try {
return await this.api.Call<Invoice>(
'POST',
`invoices/${invoiceId}/mark_uncollectible`
);
} finally {
this.loading.set(false);
}
}
}
6 changes: 6 additions & 0 deletions apps/web/src/app/features/account/account.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export const accountRoutes: Routes = [
(m) => m.subscriptionRoutes
),
},
{
path: 'invoices',
canActivate: [platformOnlyGuard],
loadChildren: () =>
import('./invoices/invoices.routes').then((m) => m.invoiceRoutes),
},
{
path: 'connected-accounts',
canActivate: [platformOnlyGuard],
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/app/features/account/invoices/invoices.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Routes } from '@angular/router';

export const invoiceRoutes: Routes = [
{
path: '',
children: [
{
path: '',
loadComponent: () =>
import('./views/invoice-list/invoice-list.component').then(
(m) => m.InvoiceListComponent
),
},
],
},
];
48 changes: 48 additions & 0 deletions apps/web/src/app/features/account/invoices/util/invoice-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type {
Customer,
Invoice,
Price,
Subscription,
} from '@zoneless/shared-types';
import { FormatIntervalLabel } from '../../products/util/price-display';

export function FormatInvoiceNumber(invoice: Invoice): string {
return invoice.number ?? '—';
}

export function FormatInvoiceCustomerName(invoice: Invoice): string {
if (invoice.customer_name) return invoice.customer_name;

const customer = invoice.customer;
if (!customer || typeof customer === 'string') return '—';
return customer.name ?? '—';
}

export function FormatInvoiceCustomerEmail(invoice: Invoice): string {
if (invoice.customer_email) return invoice.customer_email;

const customer = invoice.customer;
if (!customer) return '—';
if (typeof customer === 'string') return customer;
return (customer as Customer).email ?? customer.id;
}

/**
* Billing frequency for subscription-backed invoices (e.g. Monthly, Yearly).
* One-off invoices return an em dash.
*/
export function FormatInvoiceFrequency(invoice: Invoice): string {
const parent = invoice.parent;
if (!parent || parent.type !== 'subscription_details') return '—';

const subscription = parent.subscription_details?.subscription;
if (!subscription || typeof subscription === 'string') return '—';

const price = (subscription as Subscription).items?.data?.[0]?.price;
if (!price || typeof price === 'string') return '—';

const interval = (price as Price).recurring?.interval;
if (!interval) return '—';

return FormatIntervalLabel(interval);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<div class="header-wrapper">
<h1>Invoices</h1>
<button
type="button"
class="action-button action-button-dainty action-button-disabled"
disabled
>
<img class="button-icon" src="assets/icons/add.svg" alt="" />
<span>Create invoice</span>
</button>
</div>

<div class="field">
<div class="field-pill">
<div
class="field-pill-option"
role="button"
tabindex="0"
[class.field-pill-option-selected]="invoicesStatusTab() === 'all'"
(click)="SetInvoicesStatusTab('all')"
(keydown.enter)="SetInvoicesStatusTab('all')"
(keydown.space)="SetInvoicesStatusTab('all')"
>
<span>All invoices</span>
</div>
<div
class="field-pill-option"
role="button"
tabindex="0"
[class.field-pill-option-selected]="invoicesStatusTab() === 'draft'"
(click)="SetInvoicesStatusTab('draft')"
(keydown.enter)="SetInvoicesStatusTab('draft')"
(keydown.space)="SetInvoicesStatusTab('draft')"
>
<span>Draft</span>
</div>
<div
class="field-pill-option"
role="button"
tabindex="0"
[class.field-pill-option-selected]="invoicesStatusTab() === 'open'"
(click)="SetInvoicesStatusTab('open')"
(keydown.enter)="SetInvoicesStatusTab('open')"
(keydown.space)="SetInvoicesStatusTab('open')"
>
<span>Open</span>
</div>
<div
class="field-pill-option"
role="button"
tabindex="0"
[class.field-pill-option-selected]="invoicesStatusTab() === 'paid'"
(click)="SetInvoicesStatusTab('paid')"
(keydown.enter)="SetInvoicesStatusTab('paid')"
(keydown.space)="SetInvoicesStatusTab('paid')"
>
<span>Paid</span>
</div>
</div>
</div>

<app-paginated-list
endpoint="invoices"
[columns]="invoiceColumns"
[limit]="10"
[queryParams]="invoicesQueryParams()"
[expand]="invoicesExpand()"
></app-paginated-list>
Loading
Loading