mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user