Files
eamco_office_frontend/src/pages/pay/service/pay_service.vue
2025-10-30 20:39:14 -04:00

808 lines
30 KiB
Vue
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- src/pages/pay/service/pay_service.vue -->
<template>
<div class="flex">
<div class="w-full px-4 md:px-10 py-4">
<!-- Breadcrumbs & Title -->
<div class="text-sm breadcrumbs">
<ul>
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
<!-- Add a link to the customer's profile if the data is available -->
<li v-if="customer && customer.id">
<router-link :to="{ name: 'customerProfile', params: { id: customer.id } }">
{{ customer.customer_first_name }} {{ customer.customer_last_name }}
</router-link>
</li>
<li>Confirm Service Payment</li>
</ul>
</div>
<h1 class="text-3xl font-bold mt-4 border-b border-gray-600 pb-2">
Confirm Service Payment #{{ service.id }}
</h1>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 my-6">
<!-- LEFT COLUMN: Customer and Delivery Details -->
<div class="space-y-6">
<!-- Customer Info Card -->
<div class="bg-neutral rounded-lg p-5">
<div class="flex justify-between items-center mb-4">
<div>
<div class="text-xl font-bold">{{ customer.customer_first_name }} {{ customer.customer_last_name }}</div>
<div class="text-sm text-gray-400">Account: {{ customer.account_number }}</div>
</div>
<router-link v-if="customer && customer.id" :to="{ name: 'customerProfile', params: { id: customer.id } }" class="btn btn-secondary btn-sm">
View Profile
</router-link>
</div>
<div class="space-y-1">
<div>{{ customer.customer_address }}</div>
<div v-if="customer.customer_apt && customer.customer_apt !== 'None'">Apt: {{ customer.customer_apt }}</div>
<div>{{ customer.customer_town }}, {{ customer.customer_state === 0 ? 'MA' : 'RI' }} {{ customer.customer_zip }}</div>
<div class="mt-2">{{ customer.customer_phone_number }}</div>
</div>
</div>
<!-- Service Details Card -->
<div class="bg-neutral rounded-lg p-5">
<h3 class="text-xl font-bold mb-4">Service Details</h3>
<div class="space-y-3">
<div>
<div class="font-bold text-sm">Service Type</div>
<div class="badge" :class="getServiceTypeColor(service.type_service_call)">
{{ getServiceTypeName(service.type_service_call) }}
</div>
</div>
<div>
<div class="font-bold text-sm">Scheduled Date</div>
<div>{{ service.scheduled_date ? formatScheduledDate(service.scheduled_date) : 'Not scheduled' }}</div>
</div>
<div>
<div class="font-bold text-sm">Description</div>
<div class="text-sm">{{ service.description || 'No description provided' }}</div>
</div>
<div>
<div class="font-bold text-sm">Total Cost</div>
<div>${{ service.service_cost || '0.00' }}</div>
</div>
</div>
</div>
</div>
<!-- RIGHT COLUMN: Payment and Pricing Details -->
<div class="space-y-6">
<!-- Authorize.net Account Status Box -->
<div v-if="customer.id" class="bg-base-100 rounded-lg p-4 border">
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<svg class="w-5 h-5 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v4a3 3 0 003 3z"/>
</svg>
<span v-if="isLoadingAuthorize" class="text-sm font-medium">
<span class="loading loading-dots loading-xs mr-2"></span>
Loading...
</span>
<span v-else-if="authorizeCheck.valid_for_charging" class="text-sm font-medium">
Authorize Account ID: {{ customer.auth_net_profile_id }}
</span>
<span v-else class="text-sm font-medium text-red-600">
{{ getAccountStatusMessage() }}
</span>
</div>
<div class="flex gap-2 flex-shrink-0" v-if="!isLoadingAuthorize">
<!-- CREATE ACCOUNT SECTION - Only show when account doesn't exist -->
<div v-if="!authorizeCheck.valid_for_charging" class="flex gap-2">
<button
v-if="credit_cards_count === 0"
@click="addCreditCard"
class="btn btn-primary btn-sm"
>
Add Card
</button>
<button
@click="createAuthorizeAccount"
:class="['btn btn-sm', credit_cards_count === 0 ? 'btn-disabled' : 'btn-primary']"
:disabled="credit_cards_count === 0"
v-if="credit_cards_count > 0"
>
Create Account
</button>
<button
v-else
@click="addCreditCard"
class="btn btn-secondary btn-sm"
>
Add Card First
</button>
</div>
<!-- DELETE ACCOUNT SECTION - Only show when account exists -->
<div v-if="authorizeCheck.valid_for_charging" class="flex gap-2">
<button
@click="showDeleteAccountModal"
class="btn btn-error btn-sm"
>
Delete Account
</button>
</div>
</div>
</div>
</div>
<!-- Service Payment Card -->
<div class="bg-neutral rounded-lg p-5">
<h3 class="text-xl font-bold mb-4">Service Payment</h3>
<div class="space-y-4">
<!-- Payment Method Selection -->
<div>
<div class="font-bold text-sm mb-2">Select Payment Method</div>
<div class="space-y-2">
<!-- Show the selected card if payment is by credit -->
<div v-for="card in credit_cards" :key="card.id">
<div v-if="card.id === service.payment_card_id" class="bg-base-100 p-3 rounded-md text-sm">
<div class="font-mono font-semibold">{{ card.type_of_card }} ending in {{ card.last_four_digits }}</div>
<div>{{ card.name_on_card }}</div>
<div>Expires: {{ card.expiration_month }}/{{ card.expiration_year }}</div>
</div>
</div>
</div>
</div>
<!-- Service Cost -->
<div class="pt-2">
<div class="divide-y divide-gray-300">
<div class="flex justify-between items-center py-3">
<span class="text-lg font-bold">Service Total</span>
<span class="text-2xl font-bold text-accent">
${{ service.service_cost || '0.00' }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Actions Card -->
<div class="bg-neutral rounded-lg p-5">
<div class="flex flex-wrap gap-4 justify-between items-center">
<!-- Pay Authorize Button -->
<button class="btn btn-success" :class="{ 'btn-disabled': !authorizeCheck.valid_for_charging }" :disabled="!authorizeCheck.valid_for_charging" @click="$router.push({ name: 'authorizeServicePreauthCharge', params: { id: $route.params.id } })">
Pay Authorize.net
</button>
<!-- A single confirm button is cleaner -->
<button class="btn btn-warning" @click="processServicePayment(1)">
Pay Tiger
</button>
<!-- Edit Service button removed due to no route defined -->
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Account Confirmation Modal -->
<div class="modal" :class="{ 'modal-open': isDeleteAccountModalVisible }">
<div class="modal-box">
<h3 class="font-bold text-lg">Confirm Account Deletion</h3>
<p class="py-4">This will permanently delete the Authorize.net account and remove all payment profiles. This action cannot be undone.</p>
<div class="modal-action">
<button @click="deleteAccount" class="btn btn-error">Delete Account</button>
<button @click="isDeleteAccountModalVisible = false" class="btn">Cancel</button>
</div>
</div>
</div>
<!-- Create Account Progress Modal -->
<div class="modal" :class="{ 'modal-open': isCreateAccountModalVisible }">
<div class="modal-box">
<h3 class="font-bold text-lg">Creating Authorize.net Account</h3>
<div class="py-4 flex flex-col items-center">
<div v-if="isCreatingAccount" class="text-center">
<span class="text-lg mb-3">Setting up your payment account...</span>
<div class="loading loading-spinner loading-lg text-primary mb-3"></div>
<p class="text-sm text-gray-600">Please wait while we create your Authorize.net customer profile.</p>
</div>
<div v-else class="text-center">
<div class="text-success mb-3">
<svg class="w-12 h-12 mx-auto mb-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<p class="text-lg font-semibold mb-2">Account Created Successfully!</p>
<div class="bg-base-200 p-3 rounded-lg mb-4">
<p class="text-sm mb-1">Authorize.net Profile ID:</p>
<p class="font-mono font-bold text-success">{{ createdProfileId }}</p>
</div>
<p class="text-sm text-gray-600">Your payment account is now ready for transactions.</p>
</div>
</div>
</div>
</div>
<!-- Duplicate Account Error Modal -->
<div class="modal" :class="{ 'modal-open': isDuplicateErrorModalVisible }">
<div class="modal-box">
<h3 class="font-bold text-lg text-error"> Duplicate Account Detected</h3>
<div class="py-4 space-y-4">
<div class="text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<p class="text-lg font-semibold">Duplicate Account in Authorize.net</p>
<p class="text-sm text-gray-600 mt-2">
A duplicate account was found in your Authorize.net merchant account.
</p>
<p class="text-sm text-gray-600 mt-2">
Customer ID: <strong>{{ customer.id }}</strong>
</p>
</div>
<div class="bg-base-200 p-4 rounded-lg">
<h4 class="font-semibold mb-2 text-warning">Action Required:</h4>
<ul class="list-disc list-inside text-sm space-y-1">
<li>Manually check your Authorize.net merchant dashboard</li>
<li>Review existing customer profiles</li>
<li>Contact support for linkage if needed</li>
</ul>
<p class="text-xs text-gray-500 mt-2">
Inconsistency between your system and Authorize.net detected.
</p>
</div>
<div class="text-center pt-2">
<p class="text-xs text-gray-500">
This profile may have been created previously and needs manual linking.
</p>
</div>
</div>
<div class="modal-action">
<button class="btn btn-primary" @click="hideDuplicateErrorModal()">
Acknowledge
</button>
</div>
</div>
</div>
<Footer />
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import axios from 'axios'
import authHeader from '../../../services/auth.header'
import Header from '../../../layouts/headers/headerauth.vue'
import SideBar from '../../../layouts/sidebar/sidebar.vue'
import Footer from '../../../layouts/footers/footer.vue'
import useValidate from "@vuelidate/core";
import { notify } from "@kyvg/vue3-notification"
import { required } from "@vuelidate/validators";
export default defineComponent({
name: 'PayService',
components: {
Header,
SideBar,
Footer,
},
data() {
return {
v$: useValidate(),
loaded: false,
user: {
user_id: 0,
},
service: {
id: 0,
scheduled_date: '',
customer_id: 0,
customer_name: '',
customer_address: '',
customer_town: '',
type_service_call: 0,
description: '',
service_cost: '',
payment_card_id: 0,
},
serviceParts: null as any,
credit_cards: [
{
id: 0,
name_on_card: '',
main_card: false,
card_number: '',
expiration_month: '',
type_of_card: '',
last_four_digits: '',
expiration_year: '',
security_number: '',
}
],
stripe: null,
customer: {
id: 0,
user_id: 0,
customer_first_name: '',
customer_last_name: '',
customer_town: '',
customer_address: '',
customer_state: 0,
customer_zip: '',
customer_apt: '',
customer_home_type: 0,
customer_phone_number: '',
account_number: '',
auth_net_profile_id: null,
},
pricing: {
price_from_supplier: 0,
price_for_customer: 0,
price_for_employee: 0,
price_same_day: 0,
price_prime: 0,
price_emergency: 0,
date: "",
},
promo_active: false,
promo: {
name_of_promotion: '',
description: '',
money_off_delivery: 0,
text_on_ticket: ''
},
priceprime: 0,
pricesameday: 0,
priceemergency: 0,
total_amount: 0,
discount: 0,
total_amount_after_discount: 0,
credit_cards_count: 0,
isLoadingAuthorize: true,
authorizeCheck: { profile_exists: false, has_payment_methods: false, missing_components: [] as string[], valid_for_charging: false },
isDeleteAccountModalVisible: false,
isCreateAccountModalVisible: false,
isCreatingAccount: false,
createdProfileId: '',
isDuplicateErrorModalVisible: false,
}
},
validations() {
return {
CreateServiceOrderForm: {
basicInfo: {
description: { required },
service_cost: { required },
},
},
};
},
created() {
this.userStatus()
},
watch: {
$route() {
},
},
mounted() {
this.getServiceOrder(this.$route.params.id)
this.getServicePartsForCustomer();
},
methods: {
userStatus() {
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
axios({
method: 'get',
url: path,
withCredentials: true,
headers: authHeader(),
})
.then((response: any) => {
if (response.data.ok) {
this.user = response.data.user;
}
})
},
getServiceOrder(service_id: any) {
let path = import.meta.env.VITE_BASE_URL + "/service/" + service_id;
axios({
method: "get",
url: path,
withCredentials: true,
headers: authHeader(),
})
.then((response: any) => {
let serviceData;
if (response.data) {
// Handle different API response structures
if (response.data.service) {
// API returns {ok: true, service: {...}} structure
serviceData = response.data.service;
} else if (Array.isArray(response.data)) {
serviceData = response.data[0]; // Array response
} else {
serviceData = response.data; // Direct object response
}
if (serviceData && serviceData.id) {
this.service = {
id: serviceData.id,
scheduled_date: serviceData.scheduled_date,
customer_id: serviceData.customer_id,
customer_name: serviceData.customer_name,
customer_address: serviceData.customer_address,
customer_town: serviceData.customer_town,
type_service_call: serviceData.type_service_call,
description: serviceData.description,
service_cost: serviceData.service_cost,
payment_card_id: serviceData.payment_card_id || 0,
};
// Fetch related data
this.getCustomer(this.service.customer_id);
this.getCreditCards(this.service.customer_id);
this.getCreditCardsCount(this.service.customer_id);
this.getServicePartsForCustomer();
} else {
console.error("API Error: Invalid service data received:", serviceData);
notify({
title: "Error",
text: "Invalid service data received",
type: "error",
});
}
} else {
console.error("API Error: No response data received");
notify({
title: "Error",
text: "Could not get service data",
type: "error",
});
}
})
.catch((error: any) => {
console.error("API Error in getServiceOrder:", error);
console.error("Error details:", error.response?.data || error.message);
notify({
title: "Error",
text: "Could not get service data",
type: "error",
});
});
},
getServicePartsForCustomer() {
if (!this.service.customer_id) return;
let path = `${import.meta.env.VITE_BASE_URL}/service/parts/customer/${this.service.customer_id}`;
axios.get(path, { headers: authHeader() })
.then((response: any) => {
this.serviceParts = response.data;
})
.catch((error: any) => {
console.error("Failed to fetch service parts:", error);
this.serviceParts = null;
});
},
getCreditCards(user_id: any) {
let path = import.meta.env.VITE_BASE_URL + '/payment/cards/' + user_id;
axios({
method: 'get',
url: path,
headers: authHeader(),
}).then((response: any) => {
this.credit_cards = response.data
})
},
getCreditCardsCount(user_id: any) {
let path = import.meta.env.VITE_BASE_URL + '/payment/cards/onfile/' + user_id;
axios({
method: 'get',
url: path,
headers: authHeader(),
}).then((response: any) => {
this.credit_cards_count = response.data.cards
})
},
getCustomer(userid: any) {
let path = import.meta.env.VITE_BASE_URL + '/customer/' + userid;
axios({
method: 'get',
url: path,
headers: authHeader(),
}).then((response: any) => {
this.customer = response.data
this.checkAuthorizeAccount();
})
},
processServicePayment(payment_type: number) {
let path = import.meta.env.VITE_BASE_URL + "/payment/service/payment/" + this.service.id + '/' + payment_type;
axios({
method: "PUT",
url: path,
})
.then((response: any) => {
if (response.data.ok) {
if (payment_type == 0) {
notify({
title: "Success",
text: "Service marked as cash payment",
type: "success",
});
}
if (payment_type == 1) {
notify({
title: "Success",
text: "Service marked as credit card payment",
type: "success",
});
}
if (payment_type == 3) {
notify({
title: "Success",
text: "Service marked as check payment",
type: "success",
});
}
if (payment_type == 11) {
notify({
title: "Success",
text: "Service payment processed via Authorize.net",
type: "success",
});
}
this.$router.push({ name: "ServiceHome" });
}
})
.catch(() => {
notify({
title: "Error",
text: "Could not process service payment",
type: "error",
});
});
},
async checkAuthorizeAccount() {
if (!this.customer.id) return;
this.isLoadingAuthorize = true;
try {
const path = `${import.meta.env.VITE_AUTHORIZE_URL}/user/check-authorize-account/${this.customer.id}`;
const response = await axios.get(path, { headers: authHeader() });
this.authorizeCheck = response.data;
// Check if the API returned an error in the response body
if (this.authorizeCheck.missing_components && this.authorizeCheck.missing_components.includes('api_error')) {
console.log("API error detected in response, calling cleanup for customer:", this.customer.id);
this.cleanupAuthorizeData();
return; // Don't set loading to false yet, let cleanup handle it
}
} catch (error) {
console.error("Failed to check authorize account:", error);
notify({ title: "Error", text: "Could not check payment account status.", type: "error" });
// Set default error state
this.authorizeCheck = {
profile_exists: false,
has_payment_methods: false,
missing_components: ['api_error'],
valid_for_charging: false
};
// Automatically cleanup the local Authorize.Net data on API error
console.log("Calling cleanupAuthorizedData for customer:", this.customer.id);
this.cleanupAuthorizeData();
} finally {
this.isLoadingAuthorize = false;
}
},
async createAuthorizeAccount() {
// Show the creating account modal
this.isCreatingAccount = true;
this.isCreateAccountModalVisible = true;
try {
const path = `${import.meta.env.VITE_AUTHORIZE_URL}/user/create-account/${this.customer.id}`;
const response = await axios.post(path, {}, { headers: authHeader() });
if (response.data.success) {
// Update local state
this.customer.auth_net_profile_id = response.data.profile_id;
this.authorizeCheck.valid_for_charging = true;
this.authorizeCheck.profile_exists = true;
this.authorizeCheck.has_payment_methods = true;
this.authorizeCheck.missing_components = [];
this.createdProfileId = response.data.profile_id;
// Refresh credit cards to get updated payment profile IDs
await this.getCreditCards(this.customer.id);
// Switch modal to success view and close after delay
setTimeout(() => {
this.isCreatingAccount = false;
setTimeout(() => {
this.isCreateAccountModalVisible = false;
this.createdProfileId = '';
notify({
title: "Success",
text: "Authorize.net account created successfully!",
type: "success"
});
}, 3000); // Show success message for 3 seconds
}, 1000); // Brief delay to show success animation
} else {
// Hide modal on error
this.isCreateAccountModalVisible = false;
// Check for E00039 duplicate error
const errorMessage = response.data.message || response.data.error_detail || "Failed to create Authorize.net account";
if (response.data.is_duplicate || errorMessage.includes("E00039")) {
// Show duplicate account popup
setTimeout(() => {
this.showDuplicateErrorModal();
}, 300);
return;
} else {
// Normal error notification
notify({
title: "Error",
text: errorMessage,
type: "error"
});
}
}
} catch (error: any) {
console.error("Failed to create account:", error);
this.isCreateAccountModalVisible = false;
this.isCreatingAccount = false;
// Check for E00039 duplicate error
const errorMessage = error.response?.data?.error_detail ||
error.response?.data?.detail ||
error.response?.data?.message ||
error.message || "Failed to create Authorize.net account";
if (error.response?.data?.is_duplicate || errorMessage.includes("E00039")) {
// Show duplicate account popup
setTimeout(() => {
this.showDuplicateErrorModal();
}, 300);
return;
}
// Normal error notification
notify({
title: "Error",
text: errorMessage,
type: "error"
});
}
},
showDeleteAccountModal() {
this.isDeleteAccountModalVisible = true;
},
showDuplicateErrorModal() {
this.isDuplicateErrorModalVisible = true;
},
hideDuplicateErrorModal() {
this.isDuplicateErrorModalVisible = false;
},
addCreditCard() {
// Redirect to add card page
this.$router.push({ name: 'cardadd', params: { customerId: this.customer.id } });
},
async deleteAccount() {
this.isDeleteAccountModalVisible = false;
try {
const path = `${import.meta.env.VITE_AUTHORIZE_URL}/user/delete-account/${this.customer.id}`;
const response = await axios.delete(path, { headers: authHeader() });
if (response.data.success) {
// Update local state
this.customer.auth_net_profile_id = null;
this.authorizeCheck.valid_for_charging = false;
this.authorizeCheck.profile_exists = false;
this.authorizeCheck.has_payment_methods = false;
// Refresh credit cards list (IDs should now be null)
this.getCreditCards(this.customer.id);
notify({
title: "Success",
text: "Authorize.net account deleted successfully",
type: "success"
});
} else {
notify({
title: "Warning",
text: response.data.message || "Account deletion completed with warnings",
type: "warning"
});
}
} catch (error: any) {
console.error("Failed to delete account:", error);
notify({
title: "Error",
text: "Failed to delete Authorize.net account",
type: "error"
});
}
},
async cleanupAuthorizeData() {
try {
const path = `${import.meta.env.VITE_BASE_URL}/payment/authorize/cleanup/${this.customer.id}`;
const response = await axios.post(path, {}, { headers: authHeader() });
if (response.data.ok) {
// Update local state to reflect cleanup
this.customer.auth_net_profile_id = null;
this.authorizeCheck.valid_for_charging = false;
this.authorizeCheck.profile_exists = false;
this.authorizeCheck.has_payment_methods = false;
// Refresh credit cards to reflect null payment profile IDs
this.getCreditCards(this.customer.id);
console.log("Successfully cleaned up Authorize.Net data:", response.data.message);
} else {
console.error("Failed to cleanup Authorize.Net data:", response.data.error);
}
} catch (error) {
console.error("Error during cleanup:", error);
}
},
getAccountStatusMessage(): string {
if (!this.authorizeCheck || !this.authorizeCheck.missing_components) {
return 'Account setup incomplete';
}
const missing = this.authorizeCheck.missing_components;
if (missing.includes('customer_not_found')) {
return 'Customer not found in Authorize.net';
} else if (missing.includes('authorize_net_profile')) {
return 'No Authorize.net profile configured';
} else if (missing.includes('authorize_net_profile_invalid')) {
return 'Authorize.net profile is invalid';
} else if (missing.includes('payment_method')) {
return 'No payment methods configured';
} else if (missing.includes('api_error')) {
return 'Error checking account status';
} else {
return 'Account requires setup';
}
},
getServiceTypeName(typeId: number): string {
const typeMap: { [key: number]: string } = { 0: 'Tune-up', 1: 'No Heat', 2: 'Fix', 3: 'Tank Install', 4: 'Other' };
return typeMap[typeId] || 'Unknown';
},
getServiceTypeColor(typeId: number): string {
const colorMap: { [key: number]: string } = { 0: 'primary', 1: 'error', 2: 'warning', 3: 'info', 4: 'neutral' };
return `badge-${colorMap[typeId] || 'neutral'}`;
},
formatScheduledDate(dateString: string): string {
if (!dateString) return 'Not scheduled';
return dateString; // Could format with dayjs if needed
}
},
})
</script>
<style scoped></style>