mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
changes export flow
This commit is contained in:
@@ -61,11 +61,14 @@ import {
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
PAYMENT_GRACE_MS,
|
||||
PAYMENT_REMINDER_LEAD_MS,
|
||||
} from "./booking-batch.constants";
|
||||
import {
|
||||
LocomotiveLimits,
|
||||
WagonTypeDimensions,
|
||||
bookingCargoTons,
|
||||
bulkItemWagonsRequired,
|
||||
bookingGrossWeightTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
sizePartialOfferWagons,
|
||||
@@ -115,6 +118,32 @@ export interface ExportSpaceReport {
|
||||
fullMessage: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One export train the customer can pick for a shipment day: live free-wagon
|
||||
* space measured against THE BOOKING'S allowed wagon types (so the per-type
|
||||
* list doubles as "what cargo this train can take for you"). Unpaid holds
|
||||
* count as taken; lapsed holds free up via the lazy-expiry capacity filter.
|
||||
*/
|
||||
export interface ExportTrainOption {
|
||||
scheduleId: string;
|
||||
departure: Date;
|
||||
/** Booking cutoff for this train (windowClosesAt), null on legacy rows. */
|
||||
bookingClosesAt: Date | null;
|
||||
/** Whether the export FCFS window is open for booking right now. */
|
||||
isOpen: boolean;
|
||||
/** Best bookable wagons across the booking's allowed types. */
|
||||
freeWagons: number;
|
||||
/** Wagons this booking needs — `fits` = freeWagons >= neededWagons. */
|
||||
neededWagons: number;
|
||||
fits: boolean;
|
||||
byWagonType: Array<{
|
||||
wagonTypeId: string | null;
|
||||
code: string | null;
|
||||
name: string | null;
|
||||
freeWagons: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
originYardId: string;
|
||||
@@ -663,12 +692,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
// A customer-picked train narrows the scan to that ONE schedule: export
|
||||
// FCFS honors the pick or fails loudly (exportFullMessage names it).
|
||||
const requestedId = booking.requestedTrainScheduleId ?? null;
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
this.isFillable(s),
|
||||
this.isFillable(s) &&
|
||||
(!requestedId || s.id === requestedId),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
@@ -755,13 +788,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/** Customer-facing "train is full" copy carrying the bookable leftover. */
|
||||
private exportFullMessage(booking: Booking, report: ExportSpaceReport): string {
|
||||
const picked = Boolean(booking.requestedTrainScheduleId);
|
||||
if (!report.trainsForDay || !report.corridorMatched) {
|
||||
return 'No export train is accepting bookings for this day';
|
||||
return picked
|
||||
? 'The selected train is no longer accepting bookings — pick another train or day.'
|
||||
: 'No export train is accepting bookings for this day';
|
||||
}
|
||||
const best = report.bestAvailable;
|
||||
const base =
|
||||
'Not enough train space — an export booking must ride a single train whole, ' +
|
||||
'and no open train on this day can carry it. ';
|
||||
const base = picked
|
||||
? 'Not enough space left on the selected train — an export booking must ' +
|
||||
'ride one train whole. '
|
||||
: 'Not enough train space — an export booking must ride a single train whole, ' +
|
||||
'and no open train on this day can carry it. ';
|
||||
if (!best || best.wagons <= 0) {
|
||||
return base + 'No capacity is left on this day — pick another shipment day.';
|
||||
}
|
||||
@@ -864,6 +902,88 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The export train picker: every export train on the booking's corridor/day
|
||||
* with its live space, measured per allowed wagon type so the customer sees
|
||||
* what each train can still take for THEIR cargo. Includes full/not-yet-open
|
||||
* trains (freeWagons 0 / isOpen false) so the UI can show them disabled —
|
||||
* the request-time gate (exportSpaceReport) stays the enforcement point.
|
||||
*/
|
||||
async exportTrainOptionsForDay(
|
||||
booking: Booking,
|
||||
day: string,
|
||||
): Promise<ExportTrainOption[]> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
s.direction === 'EXPORT',
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const allowed = this.allowedDimsWithTypes(booking, wagonDims);
|
||||
const neededWagons = this.wagonsFor(booking, wagonDims);
|
||||
const typeIds = allowed
|
||||
.map((a) => a.wagonTypeId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const types = typeIds.length
|
||||
? await this.dataSource
|
||||
.getRepository(WagonType)
|
||||
.find({ where: { id: In(typeIds) } })
|
||||
: [];
|
||||
const typeById = new Map(types.map((t) => [t.id, t]));
|
||||
|
||||
const out: ExportTrainOption[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||
const room = budget.remainingFor(leg);
|
||||
const byWagonType = allowed.map(({ wagonTypeId, dims }) => {
|
||||
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
|
||||
return {
|
||||
wagonTypeId,
|
||||
code: type?.code ?? null,
|
||||
name: type?.name ?? null,
|
||||
freeWagons: this.bookableWithin(room, dims).wagons,
|
||||
};
|
||||
});
|
||||
const freeWagons = byWagonType.reduce(
|
||||
(best, t) => Math.max(best, t.freeWagons),
|
||||
0,
|
||||
);
|
||||
out.push({
|
||||
scheduleId: schedule.id,
|
||||
departure: schedule.scheduledDepartureDate!,
|
||||
bookingClosesAt: schedule.windowClosesAt ?? null,
|
||||
isOpen: this.isFillable(schedule),
|
||||
freeWagons,
|
||||
neededWagons,
|
||||
fits: freeWagons >= neededWagons,
|
||||
byWagonType,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
|
||||
* summed across every train on the booking's corridor that day. Unlike the
|
||||
@@ -2207,7 +2327,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
if (!this.fits(offeredNeed, budget)) return null;
|
||||
|
||||
const deadline = new Date(Date.now() + (await this.paymentWindowMs()));
|
||||
const deadline = new Date(
|
||||
Date.now() +
|
||||
(await this.paymentWindowMsFor(await this.scheduleById(scheduleId))),
|
||||
);
|
||||
await this.splitService.createOffer(booking, scheduleId, sized, deadline);
|
||||
// Reserve like a normal batch selection, but the partial invoice + partial
|
||||
// pay-now notification were already produced by createOffer.
|
||||
@@ -2216,6 +2339,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
status: "SELECTED_FOR_BATCH",
|
||||
selectedForBatchAt: new Date(),
|
||||
paymentDeadline: deadline,
|
||||
paymentReminderSentAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = scheduleId;
|
||||
return offeredNeed;
|
||||
@@ -2244,9 +2368,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const isPaid = (b: Booking) =>
|
||||
b.paymentStatus === "PAID" || b.status === "PAID";
|
||||
// Grace: a payment started inside the window may land minutes late via the
|
||||
// gateway webhook — don't expire until the slack has passed too.
|
||||
const isExpired = (b: Booking) =>
|
||||
b.paymentDeadline
|
||||
? b.paymentDeadline.getTime() <= now
|
||||
? b.paymentDeadline.getTime() + PAYMENT_GRACE_MS <= now
|
||||
: expireUnpaidUnknownDeadline;
|
||||
|
||||
for (const booking of reserved) {
|
||||
@@ -2544,6 +2670,39 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.notifyBoardChanged(newScheduleId, "booking_moved");
|
||||
}
|
||||
|
||||
/**
|
||||
* One reminder per hold, shortly before its pay deadline (the window tick
|
||||
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
|
||||
* bookings — a landed payment the settle hasn't processed yet needs no nag.
|
||||
*/
|
||||
async sendPaymentReminders(): Promise<void> {
|
||||
const now = new Date();
|
||||
const due = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder("b")
|
||||
.leftJoinAndSelect("b.company", "company")
|
||||
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||
.andWhere(`b.payment_status != 'PAID'`)
|
||||
.andWhere("b.payment_reminder_sent_at IS NULL")
|
||||
.andWhere("b.payment_deadline > :now", { now })
|
||||
.andWhere("b.payment_deadline <= :soon", {
|
||||
soon: new Date(now.getTime() + PAYMENT_REMINDER_LEAD_MS),
|
||||
})
|
||||
.getMany();
|
||||
for (const booking of due) {
|
||||
// Stamp BEFORE sending so a slow notifier can't double-send next tick.
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
paymentReminderSentAt: new Date(),
|
||||
} as never);
|
||||
if (booking.paymentDeadline) {
|
||||
await this.notifier.payDeadlineApproaching(
|
||||
booking,
|
||||
booking.paymentDeadline,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */
|
||||
async expireReservation(bookingId: string): Promise<void> {
|
||||
const booking = await this.dataSource
|
||||
@@ -2564,6 +2723,52 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer cancel of an unpaid hold: the same immediate release as
|
||||
* expireReservation, but the booking ends CANCELLED (the customer chose to
|
||||
* walk away — "payment window missed" copy would be wrong). Consolidated
|
||||
* pairs are rejected by the caller: the shared wagon is both-or-neither.
|
||||
*/
|
||||
async cancelReservation(bookingId: string): Promise<void> {
|
||||
const booking = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
const freedScheduleId = booking.trainScheduleId;
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: null,
|
||||
requestedTrainScheduleId: null,
|
||||
status: "CANCELLED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
paymentReminderSentAt: null,
|
||||
} as never);
|
||||
// An unpaid partial offer dies with the hold — same as expire().
|
||||
if (this.splitService) {
|
||||
await this.splitService.expireOpenOffer(booking.id);
|
||||
}
|
||||
await this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
booking.id,
|
||||
"PREPAID",
|
||||
);
|
||||
if (freedScheduleId) {
|
||||
// Same release choreography as expireReservation: reopen a FULL window,
|
||||
// top up from the waiting list, push one board update with final state.
|
||||
await this.refreshWindowStatus(freedScheduleId);
|
||||
const topUpReserved = await this.topUpFill(freedScheduleId);
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(freedScheduleId);
|
||||
}
|
||||
this.notifyBoardChanged(freedScheduleId, "reservation_expired");
|
||||
}
|
||||
this.logger.log(
|
||||
`[BATCH] CANCELLED hold ${booking.reference} — customer released the ` +
|
||||
`reservation before paying; wagons freed`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- intercity ride-along API ---------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -2648,14 +2853,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
let deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||
const targetSchedule = await this.scheduleById(scheduleId);
|
||||
let deadline = new Date(
|
||||
now.getTime() + (await this.paymentWindowMsFor(targetSchedule)),
|
||||
);
|
||||
// EXPORT parity: pay windows on an export train never outlive its booking
|
||||
// window — export bookings expire at close, so anything reserved onto the
|
||||
// same train (FCFS export or an intercity ride-along) must too. Import
|
||||
// keeps the plain payment window; its cycles re-fill after settle.
|
||||
const targetSchedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: scheduleId } });
|
||||
if (targetSchedule?.direction === "EXPORT") {
|
||||
const cutoff =
|
||||
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
|
||||
@@ -2673,6 +2878,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
status: "SELECTED_FOR_BATCH",
|
||||
selectedForBatchAt: now,
|
||||
paymentDeadline: deadline,
|
||||
paymentReminderSentAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = scheduleId;
|
||||
// The invoice was generated DRAFT at booking creation / operation-accept,
|
||||
@@ -2860,10 +3066,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const freedScheduleId = booking.trainScheduleId;
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: null,
|
||||
// The customer's train pick died with the hold — a rebook re-picks.
|
||||
requestedTrainScheduleId: null,
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
paymentReminderSentAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = null;
|
||||
// The wagons this reservation held are back — a schedule parked at FULL
|
||||
@@ -3406,7 +3615,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const byWeight =
|
||||
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
||||
|
||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight);
|
||||
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
|
||||
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
|
||||
const byItems = bulkItemWagonsRequired(booking, capacityTons);
|
||||
|
||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3602,6 +3815,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* representative dims when no allowed type is configured.
|
||||
*/
|
||||
private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] {
|
||||
return this.allowedDimsWithTypes(booking, wagonDims).map((p) => p.dims);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same allowed set as {@link dimsForAllowed} but keeping each wagon-type id,
|
||||
* so callers (the export train picker) can label per-type availability.
|
||||
* `wagonTypeId` is null only on the unconfigured fallback entry.
|
||||
*/
|
||||
private allowedDimsWithTypes(
|
||||
booking: Booking,
|
||||
wagonDims: WagonDims,
|
||||
): Array<{ wagonTypeId: string | null; dims: PerWagonDims }> {
|
||||
const fallback =
|
||||
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
||||
const ids =
|
||||
@@ -3611,19 +3836,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||||
.map((wt) => wt.id);
|
||||
const seen = new Set<string>();
|
||||
const dims: PerWagonDims[] = [];
|
||||
const out: Array<{ wagonTypeId: string | null; dims: PerWagonDims }> = [];
|
||||
for (const id of ids) {
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const d = wagonDims.byWagonTypeId.get(id);
|
||||
if (d) {
|
||||
dims.push({
|
||||
...d,
|
||||
capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
|
||||
out.push({
|
||||
wagonTypeId: id,
|
||||
dims: {
|
||||
...d,
|
||||
capacityTons:
|
||||
d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return dims.length ? dims : [fallback];
|
||||
return out.length ? out : [{ wagonTypeId: null, dims: fallback }];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3808,8 +4037,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
// Lazy-expiry guard: a hold whose deadline + grace has lapsed no longer
|
||||
// blocks capacity, even before the 10s sweep flips it to EXPIRED — so
|
||||
// availability shown to the next customer is honest between ticks.
|
||||
const graceCutoff = Date.now() - PAYMENT_GRACE_MS;
|
||||
const reserved = (
|
||||
await this.bookingsRepository.findReservedForSchedule(schedule.id)
|
||||
).filter(
|
||||
(b) =>
|
||||
b.paymentStatus === "PAID" ||
|
||||
b.status === "PAID" ||
|
||||
b.paymentDeadline == null ||
|
||||
b.paymentDeadline.getTime() > graceCutoff,
|
||||
);
|
||||
for (const b of [...allocated, ...reserved]) {
|
||||
budget.subtract(
|
||||
@@ -3905,7 +4144,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
b.paymentStatus !== "PAID" &&
|
||||
b.status !== "PAID" &&
|
||||
b.paymentDeadline != null &&
|
||||
b.paymentDeadline.getTime() > now,
|
||||
b.paymentDeadline.getTime() + PAYMENT_GRACE_MS > now,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4089,10 +4328,33 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// ---- timer plumbing -------------------------------------------------------
|
||||
|
||||
/** Configured customer pay window in ms (global rules, with defaults). */
|
||||
private async paymentWindowMs(): Promise<number> {
|
||||
/**
|
||||
* Effective customer pay window in ms for a target schedule: the staff
|
||||
* per-schedule override wins, else the global value for the schedule's
|
||||
* direction (export and import pay windows are tuned independently).
|
||||
* No schedule (unknown target) falls back to the import global.
|
||||
*/
|
||||
private async paymentWindowMsFor(
|
||||
schedule?: Pick<
|
||||
TrainSchedule,
|
||||
"direction" | "rulePaymentWindowMinutes"
|
||||
> | null,
|
||||
): Promise<number> {
|
||||
if (schedule?.rulePaymentWindowMinutes != null) {
|
||||
return schedule.rulePaymentWindowMinutes * 60_000;
|
||||
}
|
||||
const cfg = await this.trainSchedulingService.getWindowConfig();
|
||||
return cfg.paymentWindowMinutes * 60_000;
|
||||
const minutes =
|
||||
schedule?.direction === "EXPORT"
|
||||
? cfg.exportPaymentWindowMinutes
|
||||
: cfg.paymentWindowMinutes;
|
||||
return minutes * 60_000;
|
||||
}
|
||||
|
||||
private scheduleById(id: string): Promise<TrainSchedule | null> {
|
||||
return this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
private timeoutName(scheduleId: string): string {
|
||||
@@ -4104,8 +4366,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* engine's minute tick calling settleDueReservations off `paymentDeadline`.
|
||||
*/
|
||||
private armSettle(scheduleId: string): void {
|
||||
void this.paymentWindowMs()
|
||||
.then((delayMs) => {
|
||||
void this.scheduleById(scheduleId)
|
||||
.then((schedule) => this.paymentWindowMsFor(schedule))
|
||||
// The timer covers the grace too — firing at the bare deadline would
|
||||
// settle before the sweep's grace cutoff and find nothing to expire.
|
||||
.then((windowMs: number) => {
|
||||
const delayMs = windowMs + PAYMENT_GRACE_MS;
|
||||
this.removeTimeout(scheduleId);
|
||||
const handle = setTimeout(() => {
|
||||
void this.settleBatch(scheduleId).catch((err) =>
|
||||
@@ -4138,7 +4404,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: scheduleId } });
|
||||
if (!schedule || schedule.windowPhase !== "PAYMENT") return;
|
||||
const windowMs = await this.paymentWindowMs();
|
||||
const windowMs = await this.paymentWindowMsFor(schedule);
|
||||
let target = new Date(Date.now() + windowMs);
|
||||
if (
|
||||
schedule.scheduledDepartureDate &&
|
||||
|
||||
Reference in New Issue
Block a user