mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
style: clean up the invoice and setup event for warehouse.
This commit is contained in:
@@ -89,7 +89,7 @@ describe("BillingService.generateInvoice", () => {
|
||||
expect(invoice.sourceId).toBe("booking-1");
|
||||
expect(invoice.totalAmount).toBe(1500);
|
||||
expect(invoice.issuedAt).toBeInstanceOf(Date);
|
||||
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
|
||||
expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/);
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -96,11 +96,6 @@ export interface GenerateInvoiceInput {
|
||||
* (default PENDING) stamps `issuedAt`.
|
||||
*/
|
||||
status?: Freight.InvoiceStatus;
|
||||
/**
|
||||
* Document number prefix for this source (e.g. `WHF` for warehouse fees);
|
||||
* defaults to `FRT`. The daily sequence is allocated per prefix.
|
||||
*/
|
||||
numberCode?: string;
|
||||
}
|
||||
|
||||
/** Payload broadcast on `${source}.invoice.<event>`. */
|
||||
@@ -665,11 +660,12 @@ export class BillingService {
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: sourceId,
|
||||
source: invoice.source,
|
||||
// Gateway reference type derives from the invoice source by convention
|
||||
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
|
||||
// the domain never supplies it. New sources add their uppercased value to
|
||||
// the PaymentReferenceType enum.
|
||||
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
|
||||
// Freight payments settle under the generic SHIPMENT reference — how the
|
||||
// payment service attributes them to the freight API. The payment ↔ invoice
|
||||
// link is the intent id (`paymentId`); per-source post-payment reactions live
|
||||
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber,
|
||||
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||
currency: invoice.currency,
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
Res,
|
||||
Body,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
@@ -18,9 +16,9 @@ import {
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
||||
import { BookingView } from "../../common/booking-guards";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { IntentStatusDto, RefundDto } from "./payments.dto";
|
||||
import { IntentStatusDto } from "./payments.dto";
|
||||
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
@@ -73,13 +71,6 @@ export class PaymentController {
|
||||
return this.paymentService.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Post("refund")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
|
||||
refund(@Body() dto: RefundDto) {
|
||||
return this.paymentService.refund(dto);
|
||||
}
|
||||
|
||||
@Get("receipt/:orderId")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
@@ -16,7 +15,6 @@ import { BillingService } from "../billing/billing.service";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as Handlebars from "handlebars";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
|
||||
import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers";
|
||||
import {
|
||||
@@ -29,7 +27,6 @@ import {
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentPlatformDto,
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
|
||||
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
||||
@@ -96,7 +93,6 @@ export class PaymentService {
|
||||
private readonly logger = new Logger(PaymentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
@@ -404,34 +400,6 @@ export class PaymentService {
|
||||
);
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
const intent = await this.paymentRepo.findOneBy({
|
||||
refId: dto.bookingId,
|
||||
type: "booking",
|
||||
});
|
||||
if (!intent || intent.status !== "success") {
|
||||
throw new BadRequestException("No successful payment to refund");
|
||||
}
|
||||
|
||||
// NOTE: refunding still mutates the booking directly — left intact pending
|
||||
// the refund redesign. TODO: route refunds through billing.refundPayable +
|
||||
// a `${source}.invoice.refunded` reaction, like settlement.
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
PaymentEntity,
|
||||
{ id: intent.id },
|
||||
{ status: "refunded", refundedAt: new Date() },
|
||||
);
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: dto.bookingId },
|
||||
{ paymentStatus: "FAILED", status: "CANCELLED" },
|
||||
);
|
||||
});
|
||||
|
||||
return { refunded: true, bookingId: dto.bookingId };
|
||||
}
|
||||
|
||||
async getActivePaymentByOrderIdAndMethod(
|
||||
orderId: string,
|
||||
method: PaymentEntity["method"],
|
||||
@@ -527,16 +495,10 @@ export class PaymentService {
|
||||
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
|
||||
);
|
||||
|
||||
// When the intent references a booking, flip the booking itself paid.
|
||||
// refId holds the booking id (the domain reference the intent opened with).
|
||||
if (intent.referenceType === PaymentReferenceType.BOOKING) {
|
||||
await this.datasource.manager.update(
|
||||
Booking,
|
||||
{ id: intent.refId },
|
||||
{ status: "PAID", paymentStatus: "PAID" },
|
||||
);
|
||||
}
|
||||
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||
// The payment service stays domain-agnostic: it settles the intent and
|
||||
// lets billing settle the invoice (markIntentSucceeded → settleByPaymentId),
|
||||
// which emits `${source}.invoice.paid`. Per-source advances (booking → PAID,
|
||||
// warehouse → release, …) live in the domain services that listen for it.
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { BillingService, InvoiceLineInput } from '../billing/billing.service';
|
||||
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
import {
|
||||
@@ -35,8 +36,6 @@ export interface PayInvoiceDto {
|
||||
|
||||
/** Warehouse fee invoices live in the global billing system under this source. */
|
||||
const SOURCE = Freight.InvoiceSource.Warehouse;
|
||||
/** Document number prefix kept for warehouse fee invoices (e.g. `WHF-20260630-00001`). */
|
||||
const NUMBER_CODE = 'WHF';
|
||||
|
||||
/** Global statuses that still owe money and therefore block terminal release. */
|
||||
const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
|
||||
@@ -216,7 +215,6 @@ export class WarehouseInvoiceService {
|
||||
currency: billingCurrency,
|
||||
lines,
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
numberCode: NUMBER_CODE,
|
||||
});
|
||||
|
||||
const detail = await this.findById(invoice.id);
|
||||
@@ -307,6 +305,21 @@ export class WarehouseInvoiceService {
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify on online (gateway) settlement — the domain side-effect of a warehouse
|
||||
* fee being paid through billing's payment flow. The counter {@link pay} path
|
||||
* notifies inline (and carries driver details from the request), so this only
|
||||
* handles gateway payments: those stamp the invoice `paymentId`, whereas a
|
||||
* counter settlement leaves it null. Skipping null-`paymentId` events avoids
|
||||
* double-notifying a counter payment that already sent its SMS.
|
||||
*/
|
||||
@OnEvent('warehouse.invoice.paid')
|
||||
async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
if (!payload.paymentId) return;
|
||||
const detail = await this.findById(payload.invoiceId);
|
||||
await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) });
|
||||
}
|
||||
|
||||
// ── Release blocking ──────────────────────────────────────────────────────
|
||||
/** Returns the first unpaid invoice that blocks terminal release, or null. */
|
||||
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoiceView | null> {
|
||||
|
||||
Reference in New Issue
Block a user