mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
@@ -928,7 +928,7 @@ export class CompaniesService {
|
|||||||
): Promise<ProfileResponseDto> {
|
): Promise<ProfileResponseDto> {
|
||||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
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
|
// 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
|
// 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
|
* 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
|
* this TIN actually returns — the portal never lets the customer type these
|
||||||
* once eTrade has supplied them, so a mismatch here means either stale
|
* once eTrade has supplied them. Rather than trust the client's copy (stale
|
||||||
* client state or a hand-crafted request, and either way the write is
|
* cache, hand-crafted request, or just a formatting mismatch) and reject it,
|
||||||
* refused rather than silently trusting 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,
|
company: Company,
|
||||||
dto: UpdateProfileDto,
|
dto: UpdateProfileDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const touched = ETRADE_SOURCED_FIELDS.some(
|
const touched = ETRADE_SOURCED_FIELDS.some(
|
||||||
(key) => dto[key] !== undefined,
|
(key) => key !== "tin" && dto[key] !== undefined,
|
||||||
);
|
);
|
||||||
if (!touched) return;
|
if (!touched) return;
|
||||||
|
|
||||||
const tin = dto.tin ?? company.tin;
|
const tin = dto.tin ?? company.tin;
|
||||||
const registration = await this.resolveEtradeRegistration(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,
|
companyName: registration.companyName,
|
||||||
licenceNumber: registration.licenceNumber,
|
licenceNumber: registration.licenceNumber,
|
||||||
statusDescription: registration.statusDescription,
|
statusDescription: registration.statusDescription,
|
||||||
@@ -3251,21 +3253,11 @@ export class CompaniesService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const key of ETRADE_SOURCED_FIELDS) {
|
for (const key of ETRADE_SOURCED_FIELDS) {
|
||||||
const submitted = dto[key];
|
if (key === "tin" || dto[key] === undefined) continue;
|
||||||
if (submitted === undefined) continue;
|
const value = fresh[key];
|
||||||
const source = expected[key];
|
// eTrade left this field blank — fall back to whatever the client sent
|
||||||
// eTrade left this field blank — the onboarding/settings card falls back
|
// (the onboarding/settings card lets the customer type it directly then).
|
||||||
// to letting the customer type it directly, so nothing to check against.
|
if (value) (dto as Record<string, unknown>)[key] = value;
|
||||||
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.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,9 +179,12 @@ export class UpdateProfileDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
houseNo?: string;
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(20)
|
@MaxLength(20)
|
||||||
@IsValidPhone()
|
|
||||||
etradePhone?: string;
|
etradePhone?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ export class ETradeService {
|
|||||||
dateRegistered: businessInfo.DateRegistered,
|
dateRegistered: businessInfo.DateRegistered,
|
||||||
renewedFrom: businessInfo.RenewedFrom,
|
renewedFrom: businessInfo.RenewedFrom,
|
||||||
renewalDate: businessInfo.RenewalDate,
|
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
|
// eTrade returns uncoded uppercase text and sometimes a zone name in the
|
||||||
// Region slot. Map it onto the canonical list; an unresolved value yields
|
// 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
|
// "" so the form asks the user to pick rather than failing validation on
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import { BillingService } from "../billing/billing.service";
|
import { BillingService } from "../billing/billing.service";
|
||||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
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
|
* 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.
|
* this HTTP endpoint remains as a transport-agnostic fallback.
|
||||||
*/
|
*/
|
||||||
@ApiTags("Internal Payments")
|
@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)
|
@UseGuards(ServiceAuthGuard)
|
||||||
@Controller("internal/payments")
|
@Controller("internal/payments")
|
||||||
export class InternalPaymentController {
|
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 }> {
|
): Promise<{ alreadyFinalized: boolean }> {
|
||||||
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
||||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
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();
|
const paidAt = opts.paidAt ?? new Date();
|
||||||
await this.paymentRepo.update(
|
await this.paymentRepo.update(
|
||||||
|
|||||||
Reference in New Issue
Block a user