barsa b1ff1e8fd3 Refactor GitHub Workflows to Consolidate Node and pnpm Setup
- Unified Node.js and pnpm setup across deploy, pr-checks, and security workflows by introducing a custom action for streamlined configuration.
- Removed redundant setup steps to enhance workflow clarity and maintainability.
- Updated security workflow to include concurrency control for better job management.
2025-12-25 19:01:00 +09:00

382 lines
11 KiB
TypeScript

/**
* Opportunity Domain - Helpers
*
* Utility functions for cancellation date calculations and validation.
* Implements the "25th rule" and rental return deadline logic.
*/
import {
CANCELLATION_DEADLINE_DAY,
RENTAL_RETURN_DEADLINE_DAY,
CANCELLATION_NOTICE,
LINE_RETURN_STATUS,
type CancellationFormData,
type CancellationOpportunityData,
type CancellationEligibility,
type CancellationMonthOption,
} from "./contract.js";
// ============================================================================
// Date Utilities
// ============================================================================
/**
* Get the last day of a month
* @param year - Full year (e.g., 2025)
* @param month - Month (1-12)
* @returns Date string in YYYY-MM-DD format
*/
export function getLastDayOfMonth(year: number, month: number): string {
// Day 0 of next month = last day of current month
const lastDay = new Date(year, month, 0).getDate();
const monthStr = String(month).padStart(2, "0");
const dayStr = String(lastDay).padStart(2, "0");
return `${year}-${monthStr}-${dayStr}`;
}
/**
* Get the rental return deadline (10th of following month)
* @param year - Year of cancellation month
* @param month - Month of cancellation (1-12)
* @returns Date string in YYYY-MM-DD format
*/
export function getRentalReturnDeadline(year: number, month: number): string {
// Move to next month
let nextYear = year;
let nextMonth = month + 1;
if (nextMonth > 12) {
nextMonth = 1;
nextYear += 1;
}
const monthStr = String(nextMonth).padStart(2, "0");
const dayStr = String(RENTAL_RETURN_DEADLINE_DAY).padStart(2, "0");
return `${nextYear}-${monthStr}-${dayStr}`;
}
/**
* Parse YYYY-MM format to year and month
*/
export function parseYearMonth(value: string): { year: number; month: number } | null {
const match = value.match(/^(\d{4})-(0[1-9]|1[0-2])$/);
if (!match) return null;
return {
year: parseInt(match[1], 10),
month: parseInt(match[2], 10),
};
}
/**
* Format a date as YYYY-MM
*/
export function formatYearMonth(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
/**
* Format a month for display (e.g., "January 2025")
*/
export function formatMonthLabel(year: number, month: number): string {
const date = new Date(year, month - 1, 1);
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
}
/**
* Format a date for display (e.g., "January 31, 2025")
*/
export function formatDateLabel(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
// ============================================================================
// Cancellation Deadline Logic
// ============================================================================
/**
* Check if current date is before the cancellation deadline for a given month
*
* Rule: Cancellation form must be received by the 25th of the cancellation month
*
* @param cancellationYear - Year of cancellation
* @param cancellationMonth - Month of cancellation (1-12)
* @param today - Current date (for testing, defaults to now)
* @returns true if can still cancel for this month
*/
export function isBeforeCancellationDeadline(
cancellationYear: number,
cancellationMonth: number,
today: Date = new Date()
): boolean {
const deadlineDate = new Date(
cancellationYear,
cancellationMonth - 1,
CANCELLATION_DEADLINE_DAY,
23,
59,
59
);
return today <= deadlineDate;
}
/**
* Get the cancellation deadline date for a month
*
* @param year - Year
* @param month - Month (1-12)
* @returns Date string in YYYY-MM-DD format
*/
export function getCancellationDeadline(year: number, month: number): string {
const monthStr = String(month).padStart(2, "0");
const dayStr = String(CANCELLATION_DEADLINE_DAY).padStart(2, "0");
return `${year}-${monthStr}-${dayStr}`;
}
/**
* Calculate the earliest month that can be selected for cancellation
*
* If today is on or after the 25th, earliest is next month.
* Otherwise, earliest is current month.
*
* @param today - Current date (for testing, defaults to now)
* @returns YYYY-MM format string
*/
export function getEarliestCancellationMonth(today: Date = new Date()): string {
const year = today.getFullYear();
const month = today.getMonth() + 1; // 1-indexed
const day = today.getDate();
if (day > CANCELLATION_DEADLINE_DAY) {
// After 25th - earliest is next month
let nextMonth = month + 1;
let nextYear = year;
if (nextMonth > 12) {
nextMonth = 1;
nextYear += 1;
}
return `${nextYear}-${String(nextMonth).padStart(2, "0")}`;
} else {
// On or before 25th - can cancel this month
return `${year}-${String(month).padStart(2, "0")}`;
}
}
/**
* Generate available cancellation months
*
* Returns 12 months starting from the earliest available month.
*
* @param today - Current date (for testing)
* @returns Array of month options
*/
export function generateCancellationMonthOptions(
today: Date = new Date()
): CancellationMonthOption[] {
const earliestMonth = getEarliestCancellationMonth(today);
const parsed = parseYearMonth(earliestMonth);
if (!parsed) return [];
let { year, month } = parsed;
const currentYearMonth = formatYearMonth(today);
const options: CancellationMonthOption[] = [];
for (let i = 0; i < 12; i++) {
const value = `${year}-${String(month).padStart(2, "0")}`;
const serviceEndDate = getLastDayOfMonth(year, month);
const rentalReturnDeadline = getRentalReturnDeadline(year, month);
const isCurrentMonth = value === currentYearMonth;
options.push({
value,
label: formatMonthLabel(year, month),
serviceEndDate,
rentalReturnDeadline,
isCurrentMonth,
});
// Move to next month
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return options;
}
/**
* Get full cancellation eligibility information
*
* @param today - Current date (for testing)
* @returns Cancellation eligibility details
*/
export function getCancellationEligibility(today: Date = new Date()): CancellationEligibility {
const availableMonths = generateCancellationMonthOptions(today);
const earliestMonth = getEarliestCancellationMonth(today);
const day = today.getDate();
// Check if current month is still available
const canCancelThisMonth = day <= CANCELLATION_DEADLINE_DAY;
const currentMonthDeadline = canCancelThisMonth
? getCancellationDeadline(today.getFullYear(), today.getMonth() + 1)
: null;
return {
canCancel: true,
earliestCancellationMonth: earliestMonth,
availableMonths,
currentMonthDeadline,
};
}
/**
* Validate that a selected cancellation month is allowed
*
* @param selectedMonth - YYYY-MM format
* @param today - Current date (for testing)
* @returns Validation result
*/
export function validateCancellationMonth(
selectedMonth: string,
today: Date = new Date()
): { valid: boolean; error?: string } {
const parsed = parseYearMonth(selectedMonth);
if (!parsed) {
return { valid: false, error: "Invalid month format" };
}
const earliestMonth = getEarliestCancellationMonth(today);
const earliestParsed = parseYearMonth(earliestMonth);
if (!earliestParsed) {
return { valid: false, error: "Unable to determine earliest month" };
}
// Compare dates
const selectedDate = new Date(parsed.year, parsed.month - 1, 1);
const earliestDate = new Date(earliestParsed.year, earliestParsed.month - 1, 1);
if (selectedDate < earliestDate) {
const deadline = CANCELLATION_DEADLINE_DAY;
return {
valid: false,
error: `Cancellation requests for this month must be submitted by the ${deadline}th. The earliest available month is ${formatMonthLabel(earliestParsed.year, earliestParsed.month)}.`,
};
}
return { valid: true };
}
// ============================================================================
// Data Transformation
// ============================================================================
/**
* Transform cancellation form data to Opportunity update data
*
* @param formData - Customer form submission
* @returns Data to update on Opportunity
*/
export function transformCancellationFormToOpportunityData(
formData: CancellationFormData
): CancellationOpportunityData {
const parsed = parseYearMonth(formData.cancellationMonth);
if (!parsed) {
throw new Error("Invalid cancellation month format");
}
const scheduledCancellationDate = getLastDayOfMonth(parsed.year, parsed.month);
// NOTE: alternativeEmail and comments go to the Cancellation Case, not to Opportunity
return {
scheduledCancellationDate,
cancellationNotice: CANCELLATION_NOTICE.RECEIVED,
lineReturnStatus: LINE_RETURN_STATUS.NOT_YET,
};
}
/**
* Calculate rental return deadline from scheduled cancellation date
*
* @param scheduledCancellationDate - End of cancellation month (YYYY-MM-DD)
* @returns Rental return deadline (10th of following month)
*/
export function calculateRentalReturnDeadline(scheduledCancellationDate: string): string {
const date = new Date(scheduledCancellationDate);
const year = date.getFullYear();
const month = date.getMonth() + 1; // 1-indexed
return getRentalReturnDeadline(year, month);
}
// ============================================================================
// Display Helpers
// ============================================================================
/**
* Get human-readable status for line return
*/
export function getLineReturnStatusLabel(status: string | undefined): {
label: string;
description: string;
} {
switch (status) {
case LINE_RETURN_STATUS.NOT_YET:
return {
label: "Return Pending",
description: "A return kit will be sent to you",
};
case LINE_RETURN_STATUS.SENT_KIT:
return {
label: "Return Kit Sent",
description: "Please return equipment using the provided kit",
};
case LINE_RETURN_STATUS.PICKUP_SCHEDULED:
return {
label: "Pickup Scheduled",
description: "Equipment pickup has been scheduled",
};
case LINE_RETURN_STATUS.RETURNED:
case "Returned1":
return {
label: "Returned",
description: "Equipment has been returned successfully",
};
case LINE_RETURN_STATUS.NTT_DISPATCH:
return {
label: "NTT Dispatch",
description: "NTT will handle equipment return",
};
case LINE_RETURN_STATUS.COMPENSATED:
return {
label: "Compensated",
description: "Compensation fee has been charged for unreturned equipment",
};
case LINE_RETURN_STATUS.NA:
return {
label: "Not Applicable",
description: "No rental equipment to return",
};
default:
return {
label: "Unknown",
description: "",
};
}
}
/**
* Check if rental equipment is applicable for a product type
*/
export function hasRentalEquipment(productType: string): boolean {
// Internet typically has rental equipment (router, modem)
// SIM and VPN do not
return productType === "Internet";
}