fix issue

This commit is contained in:
Marshal
2026-07-16 00:33:31 +00:00
parent 234a74e812
commit 41fe04652f
51 changed files with 1895 additions and 206 deletions

View File

@@ -602,6 +602,19 @@ export class BillingService {
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
// M27: a Draft invoice is not yet issued and an Expired invoice's pay
// window has closed — neither is payable. Without these guards a payment
// could settle an unissued draft or a lapsed invoice.
if (invoice.status === Freight.InvoiceStatus.Draft) {
throw new BadRequestException(
"Cannot pay a draft invoice — it must be issued first.",
);
}
if (invoice.status === Freight.InvoiceStatus.Expired) {
throw new BadRequestException(
"Cannot pay an expired invoice — its payment window has closed.",
);
}
if (round2(input.amount) > Number(invoice.balanceAmount)) {
throw new BadRequestException(
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
@@ -891,6 +904,20 @@ export class BillingService {
status: Freight.InvoiceStatus,
manager?: EntityManager,
): Promise<void> {
// M27: this is the blunt "issue a draft" override — it stamps `issuedAt` but
// does NOT touch paidAmount/balanceAmount. Its only legitimate use is the
// Draft → Pending/Issued issue transition. It must NEVER mark an invoice
// Paid/Refunded/Cancelled/Expired (or PartiallyPaid/Overdue): those carry
// balance implications and must go through the dedicated settlement methods
// (recordPayment / markInvoiceAsRefunded / cancelInvoice / expirePayable).
if (
status !== Freight.InvoiceStatus.Pending &&
status !== Freight.InvoiceStatus.Issued
) {
throw new BadRequestException(
`updateStatus only issues an invoice (→ PENDING/ISSUED); use the dedicated settlement methods to set ${status}.`,
);
}
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {

View File

@@ -103,8 +103,35 @@ export class PaymentController {
}
}
/**
* HTML-escape a value interpolated into the public checkout pages. These
* pages are served unauthenticated and the interpolated values (provider
* error messages, status strings, intent ids, redirect URLs) can carry
* attacker-influenced input — unescaped they are a reflected-XSS sink.
*/
private escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
// Only http(s) URLs may be used as a redirect target — a javascript:
// URL would execute in the victim's browser from the <a>/location.href.
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return this.buildErrorHtml("Invalid payment redirect URL");
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return this.buildErrorHtml("Invalid payment redirect URL");
}
const escaped = this.escapeHtml(url);
const jsEscaped = JSON.stringify(url);
return `<!DOCTYPE html>
<html lang="en">
<head>
@@ -126,12 +153,14 @@ export class PaymentController {
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
<script>window.location.href = ${jsEscaped};</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
private buildStatusHtml(rawStatus: string, rawIntentId: string): string {
const status = this.escapeHtml(rawStatus);
const intentId = this.escapeHtml(rawIntentId);
return `<!DOCTYPE html>
<html lang="en">
<head>
@@ -153,7 +182,8 @@ export class PaymentController {
</html>`;
}
private buildErrorHtml(message: string): string {
private buildErrorHtml(rawMessage: string): string {
const message = this.escapeHtml(rawMessage);
return `<!DOCTYPE html>
<html lang="en">
<head>