tema 1640fae457 Add new payment methods and health check endpoints in Auth and Invoices services
- Introduced `validateSignup` endpoint in AuthController for customer number validation during signup.
- Added `healthCheck` method in AuthService to verify service integrations and database connectivity.
- Implemented `getPaymentMethods`, `getPaymentGateways`, and `refreshPaymentMethods` endpoints in InvoicesController for managing user payment options.
- Enhanced InvoicesService with methods to invalidate payment methods cache and improved error handling.
- Updated currency handling across various services and components to reflect JPY as the default currency.
- Added new dependencies in package.json for ESLint configuration.
2025-08-30 15:10:24 +09:00

521 lines
21 KiB
TypeScript

"use client";
import { useState, useEffect, useMemo, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { PageLayout } from "@/components/layout/page-layout";
import { ShieldCheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { authenticatedApi } from "@/lib/api";
import { AddressConfirmation } from "@/components/checkout/address-confirmation";
import { usePaymentMethods } from "@/hooks/useInvoices";
import {
InternetPlan,
InternetAddon,
InternetInstallation,
SimPlan,
SimActivationFee,
SimAddon,
CheckoutState,
OrderItem,
buildInternetOrderItems,
buildSimOrderItems,
calculateTotals,
buildOrderSKUs,
} from "@/shared/types/catalog.types";
interface Address {
street: string | null;
streetLine2: string | null;
city: string | null;
state: string | null;
postalCode: string | null;
country: string | null;
}
function CheckoutContent() {
const params = useSearchParams();
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
const [addressConfirmed, setAddressConfirmed] = useState(false);
const [confirmedAddress, setConfirmedAddress] = useState<Address | null>(null);
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
loading: true,
error: null,
orderItems: [],
totals: { monthlyTotal: 0, oneTimeTotal: 0 },
});
// Fetch payment methods to check if user has payment method on file
const { data: paymentMethods, isLoading: paymentMethodsLoading, error: paymentMethodsError, refetch: refetchPaymentMethods } = usePaymentMethods();
const orderType = (() => {
const type = params.get("type") || "internet";
// Map to backend expected values
switch (type.toLowerCase()) {
case "sim":
return "SIM";
case "internet":
return "Internet";
case "vpn":
return "VPN";
default:
return "Other";
}
})();
const selections = useMemo(() => {
const obj: Record<string, string> = {};
params.forEach((v, k) => {
if (k !== "type") obj[k] = v;
});
return obj;
}, [params]);
useEffect(() => {
let mounted = true;
void (async () => {
try {
setCheckoutState(prev => ({ ...prev, loading: true, error: null }));
// Validate required parameters
if (!selections.plan) {
throw new Error("No plan selected. Please go back and select a plan.");
}
let orderItems: OrderItem[] = [];
if (orderType === "Internet") {
// Fetch Internet data
const [plans, addons, installations] = await Promise.all([
authenticatedApi.get<InternetPlan[]>("/catalog/internet/plans"),
authenticatedApi.get<InternetAddon[]>("/catalog/internet/addons"),
authenticatedApi.get<InternetInstallation[]>("/catalog/internet/installations"),
]);
const plan = plans.find(p => p.sku === selections.plan);
if (!plan) {
throw new Error(
`Internet plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.`
);
}
// Handle addon SKUs like SIM flow
const addonSkus: string[] = [];
const urlParams = new URLSearchParams(window.location.search);
urlParams.getAll("addonSku").forEach(sku => {
if (sku && !addonSkus.includes(sku)) {
addonSkus.push(sku);
}
});
orderItems = buildInternetOrderItems(plan, addons, installations, {
installationSku: selections.installationSku,
addonSkus: addonSkus.length > 0 ? addonSkus : undefined,
});
} else if (orderType === "SIM") {
// Fetch SIM data
const [plans, activationFees, addons] = await Promise.all([
authenticatedApi.get<SimPlan[]>("/catalog/sim/plans"),
authenticatedApi.get<SimActivationFee[]>("/catalog/sim/activation-fees"),
authenticatedApi.get<SimAddon[]>("/catalog/sim/addons"),
]);
const plan = plans.find(p => p.sku === selections.plan); // Look up by SKU instead of ID
if (!plan) {
throw new Error(
`SIM plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.`
);
}
// Handle multiple addons from URL parameters
const addonSkus: string[] = [];
if (selections.addonSku) {
// Single addon (legacy support)
addonSkus.push(selections.addonSku);
}
// Check for multiple addonSku parameters
const urlParams = new URLSearchParams(window.location.search);
urlParams.getAll("addonSku").forEach(sku => {
if (sku && !addonSkus.includes(sku)) {
addonSkus.push(sku);
}
});
orderItems = buildSimOrderItems(plan, activationFees, addons, {
addonSkus: addonSkus.length > 0 ? addonSkus : undefined,
});
}
if (mounted) {
const totals = calculateTotals(orderItems);
setCheckoutState(prev => ({
...prev,
loading: false,
orderItems,
totals,
}));
}
} catch (error) {
if (mounted) {
setCheckoutState(prev => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : "Failed to load checkout data",
}));
}
}
})();
return () => {
mounted = false;
};
}, [orderType, selections]);
const handleSubmitOrder = async () => {
try {
setSubmitting(true);
const skus = buildOrderSKUs(checkoutState.orderItems);
// Validate we have SKUs before proceeding
if (!skus || skus.length === 0) {
throw new Error("No products selected for order. Please go back and select products.");
}
// Send SKUs + configurations - backend resolves product data from SKUs,
// uses configurations for fields that cannot be inferred
const configurations: Record<string, unknown> = {};
// Extract configurations from URL params (these come from configure pages)
if (selections.accessMode) configurations.accessMode = selections.accessMode;
if (selections.simType) configurations.simType = selections.simType;
if (selections.eid) configurations.eid = selections.eid;
// VPN region is inferred from product VPN_Region__c field, no configuration needed
if (selections.activationType) configurations.activationType = selections.activationType;
if (selections.scheduledAt) configurations.scheduledAt = selections.scheduledAt;
// MNP fields (must match backend field expectations exactly)
if (selections.isMnp) configurations.isMnp = selections.isMnp;
if (selections.reservationNumber) configurations.mnpNumber = selections.reservationNumber;
if (selections.expiryDate) configurations.mnpExpiry = selections.expiryDate;
if (selections.phoneNumber) configurations.mnpPhone = selections.phoneNumber;
if (selections.mvnoAccountNumber)
configurations.mvnoAccountNumber = selections.mvnoAccountNumber;
if (selections.portingLastName) configurations.portingLastName = selections.portingLastName;
if (selections.portingFirstName)
configurations.portingFirstName = selections.portingFirstName;
if (selections.portingLastNameKatakana)
configurations.portingLastNameKatakana = selections.portingLastNameKatakana;
if (selections.portingFirstNameKatakana)
configurations.portingFirstNameKatakana = selections.portingFirstNameKatakana;
if (selections.portingGender) configurations.portingGender = selections.portingGender;
if (selections.portingDateOfBirth)
configurations.portingDateOfBirth = selections.portingDateOfBirth;
// Include address in configurations if it was updated during checkout
if (confirmedAddress) {
configurations.address = confirmedAddress;
}
const orderData = {
orderType,
skus: skus,
...(Object.keys(configurations).length > 0 && { configurations }),
};
const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData);
router.push(`/orders/${response.sfOrderId}?status=success`);
} catch (error) {
console.error("Order submission failed:", error);
let errorMessage = "Order submission failed";
if (error instanceof Error) {
errorMessage = error.message;
}
setCheckoutState(prev => ({
...prev,
error: errorMessage,
}));
} finally {
setSubmitting(false);
}
};
const handleAddressConfirmed = (address?: Address) => {
setAddressConfirmed(true);
setConfirmedAddress(address || null);
};
const handleAddressIncomplete = () => {
setAddressConfirmed(false);
setConfirmedAddress(null);
};
if (checkoutState.loading) {
return (
<PageLayout
title="Submit Order"
description="Loading order details"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="text-center py-12">Loading order submission...</div>
</PageLayout>
);
}
if (checkoutState.error) {
return (
<PageLayout
title="Submit Order"
description="Error loading order submission"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="text-center py-12">
<p className="text-red-600 mb-4">{checkoutState.error}</p>
<button onClick={() => router.back()} className="text-blue-600 hover:text-blue-800">
Go Back
</button>
</div>
</PageLayout>
);
}
return (
<PageLayout
title="Submit Order"
description="Submit your order for review and approval"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-2xl mx-auto">
{/* Address Confirmation */}
<AddressConfirmation
onAddressConfirmed={handleAddressConfirmed}
onAddressIncomplete={handleAddressIncomplete}
orderType={orderType}
/>
{/* Order Submission Message */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-6 mb-6 text-center">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<ShieldCheckIcon className="w-8 h-8 text-blue-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Submit Your Order for Review</h2>
<p className="text-gray-600 mb-4">
You&apos;ve configured your service and reviewed all details. Your order will be
submitted for review and approval.
</p>
<div className="bg-white rounded-lg p-4 border border-blue-200">
<h3 className="font-semibold text-gray-900 mb-2">What happens next?</h3>
<div className="text-sm text-gray-600 space-y-1">
<p> Your order will be reviewed by our team</p>
<p> We&apos;ll set up your services in our system</p>
<p> Payment will be processed using your card on file</p>
<p> You&apos;ll receive confirmation once everything is ready</p>
</div>
</div>
{/* Quick Totals Summary */}
<div className="mt-4 bg-white rounded-lg p-4 border border-gray-200">
<div className="flex justify-between items-center">
<span className="font-medium text-gray-700">Total:</span>
<div className="text-right">
<div className="text-xl font-bold text-gray-900">
¥{checkoutState.totals.monthlyTotal.toLocaleString()}/mo
</div>
{checkoutState.totals.oneTimeTotal > 0 && (
<div className="text-sm text-orange-600 font-medium">
+ ¥{checkoutState.totals.oneTimeTotal.toLocaleString()} one-time
</div>
)}
</div>
</div>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Billing Information</h3>
{paymentMethodsLoading ? (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<span className="text-gray-600 text-sm">Checking payment methods...</span>
</div>
</div>
) : paymentMethodsError ? (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-amber-800 text-sm font-medium">Unable to verify payment methods</p>
<p className="text-amber-700 text-sm mt-1">
We couldn&apos;t check your payment methods. If you just added a payment method, try refreshing.
</p>
<div className="flex gap-2 mt-2">
<button
onClick={async () => {
try {
// First try to refresh cache on backend
await authenticatedApi.post('/invoices/payment-methods/refresh');
console.log('Backend cache refreshed successfully');
} catch (error) {
console.warn('Backend cache refresh failed, using frontend refresh:', error);
}
// Always refetch from frontend to get latest data
try {
await refetchPaymentMethods();
console.log('Frontend cache refreshed successfully');
} catch (error) {
console.error('Frontend refresh also failed:', error);
}
}}
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
>
Refresh Cache
</button>
<button
onClick={() => router.push('/billing/payments')}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors"
>
Add Payment Method
</button>
</div>
</div>
</div>
</div>
) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<p className="text-green-800 text-sm font-medium">Payment method verified</p>
<p className="text-green-700 text-sm mt-1">
After order approval, payment will be automatically processed using your existing
payment method on file. No additional payment steps required.
</p>
</div>
</div>
</div>
) : (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-red-800 text-sm font-medium">No payment method on file</p>
<p className="text-red-700 text-sm mt-1">
You need to add a payment method before submitting your order. Please add a credit card or other payment method to proceed.
</p>
<button
onClick={() => router.push('/billing/payments')}
className="mt-2 bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors"
>
Add Payment Method
</button>
</div>
</div>
</div>
)}
</div>
{/* Debug Info - Remove in production */}
<div className="bg-gray-100 border rounded-lg p-3 mb-4 text-xs text-gray-600">
<strong>Debug Info:</strong> Address Confirmed: {addressConfirmed ? '✅' : '❌'} |
Payment Methods: {paymentMethodsLoading ? '⏳ Loading...' : paymentMethodsError ? '❌ Error' : paymentMethods ? `${paymentMethods.paymentMethods.length} found` : '❌ None'} |
Order Items: {checkoutState.orderItems.length} |
Can Submit: {!(
submitting ||
checkoutState.orderItems.length === 0 ||
!addressConfirmed ||
paymentMethodsLoading ||
!paymentMethods ||
paymentMethods.paymentMethods.length === 0
) ? '✅' : '❌'}
</div>
<div className="flex gap-4">
<button
onClick={() => {
// Construct the configure URL with current parameters to preserve data
// Add step parameter to go directly to review step
const urlParams = new URLSearchParams(params.toString());
const reviewStep = orderType === "Internet" ? "4" : "5";
urlParams.set("step", reviewStep);
const configureUrl =
orderType === "Internet"
? `/catalog/internet/configure?${urlParams.toString()}`
: `/catalog/sim/configure?${urlParams.toString()}`;
router.push(configureUrl);
}}
className="flex-1 px-6 py-4 border border-gray-300 rounded-lg text-center hover:bg-gray-50 transition-colors font-medium"
>
Back to Review
</button>
<button
onClick={() => void handleSubmitOrder()}
disabled={
submitting ||
checkoutState.orderItems.length === 0 ||
!addressConfirmed ||
paymentMethodsLoading ||
!paymentMethods ||
paymentMethods.paymentMethods.length === 0
}
className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors font-semibold text-lg shadow-md hover:shadow-lg"
>
{submitting ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Submitting Order...
</span>
) : !addressConfirmed ? (
"📍 Complete Address to Continue"
) : paymentMethodsLoading ? (
"⏳ Verifying Payment Method..."
) : !paymentMethods || paymentMethods.paymentMethods.length === 0 ? (
"💳 Add Payment Method to Continue"
) : (
"📋 Submit Order for Review"
)}
</button>
</div>
</div>
</PageLayout>
);
}
export default function CheckoutPage() {
return (
<Suspense fallback={<div className="text-center py-12">Loading checkout...</div>}>
<CheckoutContent />
</Suspense>
);
}