Merge pull request #1075 from Tria-plc/fixes

Fixes
This commit is contained in:
Nathnael Wondisha
2026-08-02 21:36:05 +03:00
committed by GitHub
6 changed files with 80 additions and 25 deletions

View File

@@ -928,7 +928,7 @@ export class CompaniesService {
): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
await this.assertEtradeFieldsAuthentic(company, dto);
await this.applyEtradeSourcedFields(company, dto);
// Naming (or renaming) a Power of Attorney is one of the writes that can
// leave the company with a representative and nothing evidencing them, so
@@ -3216,22 +3216,24 @@ export class CompaniesService {
/**
* An eTrade-sourced field can only ever hold what a fresh eTrade lookup for
* this TIN actually returns — the portal never lets the customer type these
* once eTrade has supplied them, so a mismatch here means either stale
* client state or a hand-crafted request, and either way the write is
* refused rather than silently trusting it.
* once eTrade has supplied them. Rather than trust the client's copy (stale
* cache, hand-crafted request, or just a formatting mismatch) and reject it,
* refetch eTrade ourselves and overwrite the touched fields with whatever it
* says now — the client's submitted values for these keys only matter as a
* "this field is part of the save" flag, never as data we persist.
*/
private async assertEtradeFieldsAuthentic(
private async applyEtradeSourcedFields(
company: Company,
dto: UpdateProfileDto,
): Promise<void> {
const touched = ETRADE_SOURCED_FIELDS.some(
(key) => dto[key] !== undefined,
(key) => key !== "tin" && dto[key] !== undefined,
);
if (!touched) return;
const tin = dto.tin ?? company.tin;
const registration = await this.resolveEtradeRegistration(tin);
const expected: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
const fresh: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
companyName: registration.companyName,
licenceNumber: registration.licenceNumber,
statusDescription: registration.statusDescription,
@@ -3251,21 +3253,11 @@ export class CompaniesService {
};
for (const key of ETRADE_SOURCED_FIELDS) {
const submitted = dto[key];
if (submitted === undefined) continue;
const source = expected[key];
// eTrade left this field blank — the onboarding/settings card falls back
// to letting the customer type it directly, so nothing to check against.
if (!source) continue;
const same =
key === "etradePhone"
? normalizeE164(String(submitted)) === normalizeE164(source)
: submitted === source;
if (!same) {
throw new BadRequestException(
`${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`,
);
}
if (key === "tin" || dto[key] === undefined) continue;
const value = fresh[key];
// eTrade left this field blank — fall back to whatever the client sent
// (the onboarding/settings card lets the customer type it directly then).
if (value) (dto as Record<string, unknown>)[key] = value;
}
}
}

View File

@@ -179,9 +179,12 @@ export class UpdateProfileDto {
@MaxLength(100)
houseNo?: string;
// Not validated as a phone number: eTrade-sourced, so a fresh eTrade lookup
// overwrites whatever the client sends here — see
// CompaniesService.applyEtradeSourcedFields. Presence just flags "this save
// touches an eTrade-owned field."
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
etradePhone?: string;
}

View File

@@ -108,7 +108,9 @@ export class ETradeService {
dateRegistered: businessInfo.DateRegistered,
renewedFrom: businessInfo.RenewedFrom,
renewalDate: businessInfo.RenewalDate,
renewedTo: businessInfo.RenewedTo,
// RenewedTo is ISO ("2018-07-07T00:00:00"); RenewedToDateString matches
// RenewedFrom/RenewalDate's "M/D/YYYY" format — use that for consistency.
renewedTo: businessInfo.RenewedToDateString,
// eTrade returns uncoded uppercase text and sometimes a zone name in the
// Region slot. Map it onto the canonical list; an unresolved value yields
// "" so the form asks the user to pick rather than failing validation on

View File

@@ -19,6 +19,7 @@ import {
import { PaymentService } from "./payment.service";
import { BillingService } from "../billing/billing.service";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { Public } from "@edr/api-common";
/**
* Consumer side of the payment microservice's outbox relay. Only the payment service may
@@ -28,6 +29,9 @@ import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
* this HTTP endpoint remains as a transport-agnostic fallback.
*/
@ApiTags("Internal Payments")
// Skips the global JwtGuard (no end-user JWT on a service-to-service call);
// ServiceAuthGuard below still enforces the shared SERVICE_AUTH_TOKEN.
@Public()
@UseGuards(ServiceAuthGuard)
@Controller("internal/payments")
export class InternalPaymentController {

View File

@@ -188,3 +188,45 @@ describe("PaymentClientService.confirmOtp", () => {
);
});
});
describe("PaymentService.markIntentSucceeded", () => {
const build = (rows: Record<string, unknown>[]) => {
const repo = makeRepo(rows);
const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) };
const service = new PaymentService(
repo as never,
{} as never,
billing as never,
);
return { service, repo, billing };
};
it("re-notifies billing on an already-success intent so a settle that died mid-way converges on redelivery", async () => {
const paidAt = new Date("2026-08-01T09:00:00.000Z");
const { service, repo, billing } = build([
localIntent({ status: "success", transactionId: "txn-1", paidAt }),
]);
const result = await service.markIntentSucceeded("intent-1", {
notify: true,
});
expect(result.alreadyFinalized).toBe(true);
// No re-write of the intent row…
expect(repo.update).not.toHaveBeenCalled();
// …but billing still gets the (idempotent) settle call.
expect(billing.settleByPaymentId).toHaveBeenCalledWith(
"intent-1",
"txn-1",
paidAt,
);
});
it("does not notify billing when notify is false, even when already success", async () => {
const { service, billing } = build([localIntent({ status: "success" })]);
await service.markIntentSucceeded("intent-1", { notify: false });
expect(billing.settleByPaymentId).not.toHaveBeenCalled();
});
});

View File

@@ -451,7 +451,19 @@ export class PaymentService {
): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success") return { alreadyFinalized: true };
if (intent.status === "success") {
// Still notify billing: a prior delivery may have flipped the intent to
// success and then died before the invoice settled (the two steps are not
// atomic). settleByPaymentId is idempotent — no open invoice, no-op.
if (opts.notify !== false) {
await this.billing.settleByPaymentId(
intent.id,
opts.providerTxnId ?? intent.transactionId ?? undefined,
opts.paidAt ?? intent.paidAt ?? undefined,
);
}
return { alreadyFinalized: true };
}
const paidAt = opts.paidAt ?? new Date();
await this.paymentRepo.update(