feat: add Pending status for delivery/technician tasks
- New 'pending' status allows tasks to be created without a schedule, acting as a queue for unscheduled work that gets assigned later - Pending group appears in the Delivery Map sidebar with amber color - Other modules can create tasks in pending state for scheduling - scheduled_date no longer required (null for pending tasks) - New Pending Tasks menu item under Field Service - Pending filter added to search view Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BIN
fusion_poynt/static/description/icon.png
Normal file
BIN
fusion_poynt/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
374
fusion_poynt/static/src/interactions/payment_form.js
Normal file
374
fusion_poynt/static/src/interactions/payment_form.js
Normal file
@@ -0,0 +1,374 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _t } from '@web/core/l10n/translation';
|
||||
import { patch } from '@web/core/utils/patch';
|
||||
import { rpc } from '@web/core/network/rpc';
|
||||
|
||||
import { PaymentForm } from '@payment/interactions/payment_form';
|
||||
|
||||
patch(PaymentForm.prototype, {
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.poyntFormData = {};
|
||||
},
|
||||
|
||||
// #=== DOM MANIPULATION ===#
|
||||
|
||||
async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) {
|
||||
if (providerCode !== 'poynt') {
|
||||
await super._prepareInlineForm(...arguments);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow === 'token') {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setPaymentFlow('direct');
|
||||
|
||||
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
|
||||
const inlineForm = this._getInlineForm(radio);
|
||||
const poyntContainer = inlineForm.querySelector('[name="o_poynt_payment_container"]');
|
||||
|
||||
if (!poyntContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawValues = poyntContainer.dataset['poyntInlineFormValues'];
|
||||
if (rawValues) {
|
||||
this.poyntFormData = JSON.parse(rawValues);
|
||||
}
|
||||
|
||||
this._setupCardFormatting(poyntContainer);
|
||||
this._setupTerminalToggle(poyntContainer);
|
||||
},
|
||||
|
||||
_setupCardFormatting(container) {
|
||||
const cardInput = container.querySelector('#poynt_card_number');
|
||||
if (cardInput) {
|
||||
cardInput.addEventListener('input', (e) => {
|
||||
let value = e.target.value.replace(/\D/g, '');
|
||||
let formatted = '';
|
||||
for (let i = 0; i < value.length && i < 16; i++) {
|
||||
if (i > 0 && i % 4 === 0) {
|
||||
formatted += ' ';
|
||||
}
|
||||
formatted += value[i];
|
||||
}
|
||||
e.target.value = formatted;
|
||||
});
|
||||
}
|
||||
|
||||
const expiryInput = container.querySelector('#poynt_expiry');
|
||||
if (expiryInput) {
|
||||
expiryInput.addEventListener('input', (e) => {
|
||||
let value = e.target.value.replace(/\D/g, '');
|
||||
if (value.length >= 2) {
|
||||
value = value.substring(0, 2) + '/' + value.substring(2, 4);
|
||||
}
|
||||
e.target.value = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_setupTerminalToggle(container) {
|
||||
const terminalCheckbox = container.querySelector('#poynt_use_terminal');
|
||||
const terminalSelect = container.querySelector('#poynt_terminal_select_wrapper');
|
||||
const cardFields = container.querySelectorAll(
|
||||
'#poynt_card_number, #poynt_expiry, #poynt_cvv, #poynt_cardholder'
|
||||
);
|
||||
|
||||
if (!terminalCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminalCheckbox.addEventListener('change', () => {
|
||||
if (terminalCheckbox.checked) {
|
||||
if (terminalSelect) {
|
||||
terminalSelect.style.display = 'block';
|
||||
}
|
||||
cardFields.forEach(f => {
|
||||
f.closest('.mb-3').style.display = 'none';
|
||||
f.removeAttribute('required');
|
||||
});
|
||||
this._loadTerminals(container);
|
||||
} else {
|
||||
if (terminalSelect) {
|
||||
terminalSelect.style.display = 'none';
|
||||
}
|
||||
cardFields.forEach(f => {
|
||||
f.closest('.mb-3').style.display = 'block';
|
||||
if (f.id !== 'poynt_cardholder') {
|
||||
f.setAttribute('required', 'required');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async _loadTerminals(container) {
|
||||
const selectEl = container.querySelector('#poynt_terminal_select');
|
||||
if (!selectEl || selectEl.options.length > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const terminals = await rpc('/payment/poynt/terminals', {
|
||||
provider_id: this.poyntFormData.provider_id,
|
||||
});
|
||||
|
||||
selectEl.innerHTML = '';
|
||||
if (terminals && terminals.length > 0) {
|
||||
terminals.forEach(t => {
|
||||
const option = document.createElement('option');
|
||||
option.value = t.id;
|
||||
option.textContent = `${t.name} (${t.status})`;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
} else {
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = _t('No terminals available');
|
||||
selectEl.appendChild(option);
|
||||
}
|
||||
} catch {
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = _t('Failed to load terminals');
|
||||
selectEl.appendChild(option);
|
||||
}
|
||||
},
|
||||
|
||||
// #=== PAYMENT FLOW ===#
|
||||
|
||||
async _initiatePaymentFlow(providerCode, paymentOptionId, paymentMethodCode, flow) {
|
||||
if (providerCode !== 'poynt' || flow === 'token') {
|
||||
await super._initiatePaymentFlow(...arguments);
|
||||
return;
|
||||
}
|
||||
|
||||
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
|
||||
const inlineForm = this._getInlineForm(radio);
|
||||
const useTerminal = inlineForm.querySelector('#poynt_use_terminal');
|
||||
|
||||
if (useTerminal && useTerminal.checked) {
|
||||
const terminalId = inlineForm.querySelector('#poynt_terminal_select').value;
|
||||
if (!terminalId) {
|
||||
this._displayErrorDialog(
|
||||
_t("Terminal Required"),
|
||||
_t("Please select a terminal device."),
|
||||
);
|
||||
this._enableButton();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const validationError = this._validateCardInputs(inlineForm);
|
||||
if (validationError) {
|
||||
this._displayErrorDialog(
|
||||
_t("Invalid Card Details"),
|
||||
validationError,
|
||||
);
|
||||
this._enableButton();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await super._initiatePaymentFlow(...arguments);
|
||||
},
|
||||
|
||||
_validateCardInputs(inlineForm) {
|
||||
const cardNumber = inlineForm.querySelector('#poynt_card_number');
|
||||
const expiry = inlineForm.querySelector('#poynt_expiry');
|
||||
const cvv = inlineForm.querySelector('#poynt_cvv');
|
||||
|
||||
const cardDigits = cardNumber.value.replace(/\D/g, '');
|
||||
if (cardDigits.length < 13 || cardDigits.length > 19) {
|
||||
return _t("Please enter a valid card number.");
|
||||
}
|
||||
|
||||
const expiryValue = expiry.value;
|
||||
if (!/^\d{2}\/\d{2}$/.test(expiryValue)) {
|
||||
return _t("Please enter a valid expiry date (MM/YY).");
|
||||
}
|
||||
|
||||
const [month, year] = expiryValue.split('/').map(Number);
|
||||
if (month < 1 || month > 12) {
|
||||
return _t("Invalid expiry month.");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiryDate = new Date(2000 + year, month);
|
||||
if (expiryDate <= now) {
|
||||
return _t("Card has expired.");
|
||||
}
|
||||
|
||||
const cvvValue = cvv.value.replace(/\D/g, '');
|
||||
if (cvvValue.length < 3 || cvvValue.length > 4) {
|
||||
return _t("Please enter a valid CVV.");
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
async _processDirectFlow(providerCode, paymentOptionId, paymentMethodCode, processingValues) {
|
||||
if (providerCode !== 'poynt') {
|
||||
await super._processDirectFlow(...arguments);
|
||||
return;
|
||||
}
|
||||
|
||||
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
|
||||
const inlineForm = this._getInlineForm(radio);
|
||||
const useTerminal = inlineForm.querySelector('#poynt_use_terminal');
|
||||
|
||||
if (useTerminal && useTerminal.checked) {
|
||||
await this._processTerminalPayment(processingValues, inlineForm);
|
||||
} else {
|
||||
await this._processCardPayment(processingValues, inlineForm);
|
||||
}
|
||||
},
|
||||
|
||||
async _processCardPayment(processingValues, inlineForm) {
|
||||
const cardNumber = inlineForm.querySelector('#poynt_card_number').value.replace(/\D/g, '');
|
||||
const expiry = inlineForm.querySelector('#poynt_expiry').value;
|
||||
const cvv = inlineForm.querySelector('#poynt_cvv').value;
|
||||
const cardholder = inlineForm.querySelector('#poynt_cardholder').value;
|
||||
|
||||
const [expMonth, expYear] = expiry.split('/').map(Number);
|
||||
|
||||
try {
|
||||
const result = await rpc('/payment/poynt/process_card', {
|
||||
reference: processingValues.reference,
|
||||
poynt_order_id: processingValues.poynt_order_id,
|
||||
card_number: cardNumber,
|
||||
exp_month: expMonth,
|
||||
exp_year: 2000 + expYear,
|
||||
cvv: cvv,
|
||||
cardholder_name: cardholder,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
this._displayErrorDialog(
|
||||
_t("Payment Failed"),
|
||||
result.error,
|
||||
);
|
||||
this._enableButton();
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = processingValues.return_url;
|
||||
} catch (error) {
|
||||
this._displayErrorDialog(
|
||||
_t("Payment Processing Error"),
|
||||
error.message || _t("An unexpected error occurred."),
|
||||
);
|
||||
this._enableButton();
|
||||
}
|
||||
},
|
||||
|
||||
async _processTerminalPayment(processingValues, inlineForm) {
|
||||
const terminalId = inlineForm.querySelector('#poynt_terminal_select').value;
|
||||
|
||||
try {
|
||||
const result = await rpc('/payment/poynt/send_to_terminal', {
|
||||
reference: processingValues.reference,
|
||||
terminal_id: parseInt(terminalId),
|
||||
poynt_order_id: processingValues.poynt_order_id,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
this._displayErrorDialog(
|
||||
_t("Terminal Payment Failed"),
|
||||
result.error,
|
||||
);
|
||||
this._enableButton();
|
||||
return;
|
||||
}
|
||||
|
||||
this._showTerminalWaitingScreen(processingValues, terminalId);
|
||||
} catch (error) {
|
||||
this._displayErrorDialog(
|
||||
_t("Terminal Error"),
|
||||
error.message || _t("Failed to send payment to terminal."),
|
||||
);
|
||||
this._enableButton();
|
||||
}
|
||||
},
|
||||
|
||||
_showTerminalWaitingScreen(processingValues, terminalId) {
|
||||
const container = document.querySelector('.o_poynt_payment_form');
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center p-4">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<h5>${_t("Waiting for terminal payment...")}</h5>
|
||||
<p class="text-muted">
|
||||
${_t("Please complete the payment on the terminal device.")}
|
||||
</p>
|
||||
<p class="text-muted small" id="poynt_terminal_status">
|
||||
${_t("Checking status...")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
this._pollTerminalStatus(processingValues, terminalId);
|
||||
},
|
||||
|
||||
async _pollTerminalStatus(processingValues, terminalId, attempt = 0) {
|
||||
const maxAttempts = 60;
|
||||
const pollInterval = 3000;
|
||||
|
||||
if (attempt >= maxAttempts) {
|
||||
this._displayErrorDialog(
|
||||
_t("Timeout"),
|
||||
_t("Terminal payment timed out. Please check the device."),
|
||||
);
|
||||
this._enableButton();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await rpc('/payment/poynt/terminal_status', {
|
||||
reference: processingValues.reference,
|
||||
terminal_id: parseInt(terminalId),
|
||||
});
|
||||
|
||||
const statusEl = document.getElementById('poynt_terminal_status');
|
||||
|
||||
if (result.status === 'CAPTURED' || result.status === 'AUTHORIZED') {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = _t("Payment completed! Redirecting...");
|
||||
}
|
||||
window.location.href = processingValues.return_url;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'DECLINED' || result.status === 'FAILED') {
|
||||
this._displayErrorDialog(
|
||||
_t("Payment Declined"),
|
||||
_t("The payment was declined at the terminal."),
|
||||
);
|
||||
this._enableButton();
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.textContent = _t("Status: ") + (result.status || _t("Pending"));
|
||||
}
|
||||
|
||||
setTimeout(
|
||||
() => this._pollTerminalStatus(processingValues, terminalId, attempt + 1),
|
||||
pollInterval,
|
||||
);
|
||||
} catch {
|
||||
setTimeout(
|
||||
() => this._pollTerminalStatus(processingValues, terminalId, attempt + 1),
|
||||
pollInterval,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
136
fusion_poynt/static/src/interactions/terminal_payment.js
Normal file
136
fusion_poynt/static/src/interactions/terminal_payment.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _t } from '@web/core/l10n/translation';
|
||||
import { rpc } from '@web/core/network/rpc';
|
||||
import { Component, useState } from '@odoo/owl';
|
||||
|
||||
export class TerminalPaymentWidget extends Component {
|
||||
static template = 'fusion_poynt.TerminalPaymentWidget';
|
||||
static props = {
|
||||
providerId: { type: Number },
|
||||
amount: { type: Number },
|
||||
currency: { type: String },
|
||||
reference: { type: String },
|
||||
orderId: { type: String, optional: true },
|
||||
onComplete: { type: Function, optional: true },
|
||||
onError: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
terminals: [],
|
||||
selectedTerminalId: null,
|
||||
loading: false,
|
||||
polling: false,
|
||||
status: '',
|
||||
message: '',
|
||||
});
|
||||
this._loadTerminals();
|
||||
}
|
||||
|
||||
async _loadTerminals() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const result = await rpc('/payment/poynt/terminals', {
|
||||
provider_id: this.props.providerId,
|
||||
});
|
||||
this.state.terminals = result || [];
|
||||
if (this.state.terminals.length > 0) {
|
||||
this.state.selectedTerminalId = this.state.terminals[0].id;
|
||||
}
|
||||
} catch {
|
||||
this.state.message = _t('Failed to load terminal devices.');
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onTerminalChange(ev) {
|
||||
this.state.selectedTerminalId = parseInt(ev.target.value);
|
||||
}
|
||||
|
||||
async onSendToTerminal() {
|
||||
if (!this.state.selectedTerminalId) {
|
||||
this.state.message = _t('Please select a terminal.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.loading = true;
|
||||
this.state.message = '';
|
||||
|
||||
try {
|
||||
const result = await rpc('/payment/poynt/send_to_terminal', {
|
||||
reference: this.props.reference,
|
||||
terminal_id: this.state.selectedTerminalId,
|
||||
poynt_order_id: this.props.orderId || '',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
this.state.message = result.error;
|
||||
this.state.loading = false;
|
||||
if (this.props.onError) {
|
||||
this.props.onError(result.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.polling = true;
|
||||
this.state.status = _t('Waiting for payment on terminal...');
|
||||
this._pollStatus(0);
|
||||
} catch (error) {
|
||||
this.state.message = error.message || _t('Failed to send payment to terminal.');
|
||||
this.state.loading = false;
|
||||
if (this.props.onError) {
|
||||
this.props.onError(this.state.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _pollStatus(attempt) {
|
||||
const maxAttempts = 60;
|
||||
const pollInterval = 3000;
|
||||
|
||||
if (attempt >= maxAttempts) {
|
||||
this.state.polling = false;
|
||||
this.state.loading = false;
|
||||
this.state.message = _t('Payment timed out. Please check the terminal.');
|
||||
if (this.props.onError) {
|
||||
this.props.onError(this.state.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await rpc('/payment/poynt/terminal_status', {
|
||||
reference: this.props.reference,
|
||||
terminal_id: this.state.selectedTerminalId,
|
||||
});
|
||||
|
||||
if (result.status === 'CAPTURED' || result.status === 'AUTHORIZED') {
|
||||
this.state.polling = false;
|
||||
this.state.loading = false;
|
||||
this.state.status = _t('Payment completed!');
|
||||
if (this.props.onComplete) {
|
||||
this.props.onComplete(result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'DECLINED' || result.status === 'FAILED') {
|
||||
this.state.polling = false;
|
||||
this.state.loading = false;
|
||||
this.state.message = _t('Payment was declined.');
|
||||
if (this.props.onError) {
|
||||
this.props.onError(this.state.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.status = _t('Status: ') + (result.status || _t('Pending'));
|
||||
} catch {
|
||||
this.state.status = _t('Checking...');
|
||||
}
|
||||
|
||||
setTimeout(() => this._pollStatus(attempt + 1), pollInterval);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user