mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
feat: setup the invoice backend
This commit is contained in:
@@ -2,19 +2,22 @@ import { forwardRef, Module } from "@nestjs/common";
|
|||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { BillingController } from "./billing.controller";
|
import { BillingController } from "./billing.controller";
|
||||||
|
import { PortalBillingController } from "./portal-billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { Invoice } from "./entities/invoice.entity";
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
import { InvoiceRepository } from "./invoice.repository";
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
import { PaymentModule } from "../payment/payment.module";
|
import { PaymentModule } from "../payment/payment.module";
|
||||||
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||||
forwardRef(() => PaymentModule),
|
forwardRef(() => PaymentModule),
|
||||||
|
CompaniesModule,
|
||||||
],
|
],
|
||||||
controllers: [BillingController],
|
controllers: [BillingController, PortalBillingController],
|
||||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||||
exports: [BillingService],
|
exports: [BillingService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
|
{} as never, // companies
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,6 +133,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
|
{} as never, // companies
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -168,6 +170,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
|
{} as never, // companies
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -196,6 +199,7 @@ describe("BillingService.settlePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
|
{} as never, // companies
|
||||||
);
|
);
|
||||||
|
|
||||||
const settled = await service.settlePayable(
|
const settled = await service.settlePayable(
|
||||||
@@ -229,6 +233,7 @@ describe("BillingService.settlePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
|
{} as never, // companies
|
||||||
);
|
);
|
||||||
|
|
||||||
const settled = await service.settlePayable(
|
const settled = await service.settlePayable(
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ import { InvoiceRepository } from "./invoice.repository";
|
|||||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||||
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
|
|
||||||
|
/** Options forwarded to the payment gateway when settling an invoice. */
|
||||||
|
export interface PayInvoiceOptions {
|
||||||
|
method?: string;
|
||||||
|
platform?: "web" | "mobile";
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||||
const DEFAULT_DUE_DAYS = 14;
|
const DEFAULT_DUE_DAYS = 14;
|
||||||
@@ -84,6 +94,7 @@ export class BillingService {
|
|||||||
private readonly events: EventEmitter2,
|
private readonly events: EventEmitter2,
|
||||||
@Inject(forwardRef(() => PaymentService))
|
@Inject(forwardRef(() => PaymentService))
|
||||||
private readonly payment: PaymentService,
|
private readonly payment: PaymentService,
|
||||||
|
private readonly companies: CompaniesService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
@@ -104,6 +115,64 @@ export class BillingService {
|
|||||||
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Resolve the customer's company id from their IAM user id (null if none). */
|
||||||
|
async resolveCompanyId(userId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { company } = await this.companies.getCompanyInfoByUserId(userId);
|
||||||
|
return company?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every invoice billed to a company, newest first, with billing relations. */
|
||||||
|
findByCompany(companyId: string): Promise<Invoice[]> {
|
||||||
|
return this.invoices.findAll({
|
||||||
|
where: { companyId },
|
||||||
|
relations: { company: true, companyProfile: true },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||||
|
async findForUser(userId: string): Promise<Invoice[]> {
|
||||||
|
const companyId = await this.resolveCompanyId(userId);
|
||||||
|
return companyId ? this.findByCompany(companyId) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||||
|
async findByIdForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
|
const companyId = await this.resolveCompanyId(userId);
|
||||||
|
const invoice = await this.findById(id);
|
||||||
|
if (!companyId || invoice.companyId !== companyId) {
|
||||||
|
throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
|
}
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate gateway payment for one of the customer's own invoices. Verifies
|
||||||
|
* ownership, then charges whichever open invoice the source currently has
|
||||||
|
* (see {@link payInvoice}).
|
||||||
|
*/
|
||||||
|
async payInvoiceForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
opts: PayInvoiceOptions = {},
|
||||||
|
): Promise<InitiateResponseDto> {
|
||||||
|
const invoice = await this.findByIdForUser(id, userId);
|
||||||
|
return this.payInvoice(
|
||||||
|
invoice.source as Freight.InvoiceSource,
|
||||||
|
invoice.sourceId,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Generation ───────────────────────────────────────────────────────────────
|
// ── Generation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
|
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
/** Gateway options for paying an invoice from the customer portal. */
|
||||||
|
export class PayInvoiceDto {
|
||||||
|
@ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
method?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["web", "mobile"])
|
||||||
|
platform?: "web" | "mobile";
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
payerAccount?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Browser redirect URL on success." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
returnUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Browser redirect URL on failure." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
failureUrl?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AuthUserPayload,
|
||||||
|
resolveAuthUserId,
|
||||||
|
} from "../../common/resolve-auth-user-id";
|
||||||
|
import { BillingService } from "./billing.service";
|
||||||
|
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||||
|
* org-wide), every route here is force-scoped to the signed-in customer's
|
||||||
|
* company — they only ever see and pay their own invoices.
|
||||||
|
*/
|
||||||
|
@ApiTags("billing")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller("billing")
|
||||||
|
export class PortalBillingController {
|
||||||
|
constructor(private readonly billingService: BillingService) {}
|
||||||
|
|
||||||
|
@Get("my-invoices")
|
||||||
|
@ApiOperation({ summary: "List the signed-in customer's invoices" })
|
||||||
|
findMine(@CurrentUser() user: AuthUserPayload) {
|
||||||
|
return this.billingService.findForUser(resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("my-invoices/:id")
|
||||||
|
@ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
|
||||||
|
findMineById(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("my-invoices/:id/pay")
|
||||||
|
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
|
||||||
|
pay(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Body() dto: PayInvoiceDto,
|
||||||
|
) {
|
||||||
|
return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
|
||||||
|
method: dto.method,
|
||||||
|
platform: dto.platform ?? "web",
|
||||||
|
payerAccount: dto.payerAccount,
|
||||||
|
returnUrl: dto.returnUrl,
|
||||||
|
failureUrl: dto.failureUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user