From 7762d6c2d300b097c6a6e73f92aa24c5bd3c195d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 18:28:40 +0000 Subject: [PATCH 1/3] fix: payemetn race condition --- .../modules/payment/payment.service.spec.ts | 42 +++++++++++++++++++ .../src/modules/payment/payment.service.ts | 14 ++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts index ed0f8da58..aca224c07 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts @@ -188,3 +188,45 @@ describe("PaymentClientService.confirmOtp", () => { ); }); }); + +describe("PaymentService.markIntentSucceeded", () => { + const build = (rows: Record[]) => { + 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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 985b4f53f..669c7c27b 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -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( From 3a3c309a5b450459d20e388415d5419b5b1efc9e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 18:33:38 +0000 Subject: [PATCH 2/3] fix: etrade inconsistencies --- .../modules/companies/companies.service.ts | 36 ++++++++----------- .../companies/dto/update-profile.dto.ts | 5 ++- .../companies/services/etrade.service.ts | 4 ++- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index d9798af5d..e0af07e0b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -928,7 +928,7 @@ export class CompaniesService { ): Promise { 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 { 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> = { + const fresh: Partial> = { 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)[key] = value; } } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 9f7d1ed39..596644fab 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 51bdb2df6..3292ee2d3 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -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 From dd0c7af2516efff5416959ca4a4507eb2589d86a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 18:34:12 +0000 Subject: [PATCH 3/3] fix: internal payment controller --- .../src/modules/payment/internal-payment.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index b5ff51c48..94c1268f6 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -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 {