Add Stripe payment integration and improve POS features

This commit introduces the ability to process payments via Stripe, including creating, retrieving, and deleting payment intents, and integrating this functionality into the department POS system. New UI components and logic were added to handle Stripe readers, tax computation, and payment flows. Additionally, enhancements were made to allow subscription handling in the product table and force editing of orders by superusers.
This commit is contained in:
Jepp9350
2025-04-08 14:11:44 +02:00
parent 32eb1fe4fe
commit 41b90672cc
9 changed files with 478 additions and 8 deletions
@@ -122,6 +122,12 @@ const attributes = ref([
prop: 'invoiceWithStripe',
description: 'Når denne er sat, faktureres ordren med Stripe',
icon: 'fas fa-cogs'
},
{
name: 'Only tank cleaning',
prop: 'onlyTankCleaning',
description: 'Når denne er sat, bliver kunden kategoriseret som "Tank cleaning" kunde.',
icon: 'fas fa-cogs'
}
]);
@@ -0,0 +1,317 @@
<script setup>
import { ref, defineProps } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { StripeModule } from "@/components/stripe/StripeModule.vue";
const props = defineProps({
departmentId: {
type: Number,
required: true,
},
label: {
type: String,
default: 'Næste',
},
order_id: {
type: Number,
required: true,
},
});
import { Colors } from "@/ThemeConfig.vue";
// Readers variables
const readers = ref([]);
const isLoading = ref(false);
const error = ref(null);
const isReady = ref(false);
const selectedReader = ref(null);
const isReaderSelected = ref(false);
const isReadersLoading = ref(false);
const tax_rates = ref([
{ id: 1, display_name: 'Dansk kunde', percentage: 25 },
{ id: 2, display_name: 'EU kunde', percentage: 0 },
]);
const selectedTaxRate = ref(tax_rates.value[0].id);
const getTaxRatePercentage = (taxRateId) => {
const taxRate = tax_rates.value.find(rate => rate.id === taxRateId);
return taxRate ? taxRate.percentage : 0;
}
const isReaderConnected = (reader) => {
return reader.status === 'online';
};
const isReaderAvailable = (reader) => {
return (reader.action === null || reader.action === undefined || reader.action === "") && isReaderConnected(reader);
};
const getAvailableReaders = () => {
return readers.value.filter(isReaderAvailable);
}
const attemptAutomaticReaderSelection = (readers) => {
const availableReaders = readers.filter(isReaderAvailable);
if (availableReaders.length > 0) {
selectedReader.value = availableReaders[0];
isReaderSelected.value = true;
} else {
selectedReader.value = null;
isReaderSelected.value = false;
}
};
const getStripeReaders = async () => {
isReadersLoading.value = true;
SessionUser.request(
'/modules/stripe/department/terminal/readers',
'GET',
{
id: props.departmentId,
},
).then((response) => {
console.log('Response from Stripe:', response.data.data.data);
if (response.status === 200) {
isReadersLoading.value = false;
isReady.value = true;
readers.value = response.data.data.data;
// Attempt to automatically select a reader
attemptAutomaticReaderSelection(readers.value);
return response;
} else {
isReadersLoading.value = false;
error.value = response.data.data.message;
console.error('Error fetching readers:', response);
}
}).catch((error) => {
isReadersLoading.value = false;
error.value = error.message;
console.error('Error fetching readers:', error);
})
}
const onClick = () => {
console.log('Stripe payment initiated', selectedReader.value);
isLoading.value = true;
// Start the payment process with the selected reader
StripeModule.paymentIntents.createPaymentIntent(selectedReader.value, props.order_id, getTaxRatePercentage(selectedTaxRate.value))
.then((response) => {
if (response.status === 200) {
console.log('Payment intent created successfully:', response.data.data);
// Load the payment intent
StripeModule.paymentIntents.getPaymentIntent(props.order_id);
} else {
// Handle error
error.value = response.data.message;
console.error('Error creating payment intent:', response);
}
isLoading.value = false;
})
.catch((error) => {
console.error('Error creating payment intent:', error);
// Handle error
error.value = error.message;
isLoading.value = false;
});
}
const isAnyReadersUnavailable = () => {
return readers.value.some(reader => !isReaderAvailable(reader));
}
const isAnyReadersAvailable = () => {
return readers.value.some(reader => isReaderAvailable(reader));
}
// This will return "Available", "Disconnected", "Busy", or "Unknown"
const getReaderStatus = (reader) => {
if (reader.status === 'online') {
return 'Available';
} else if (reader.status === 'offline') {
return 'Disconnected';
} else if (reader.status === 'busy') {
return 'Busy';
} else {
return 'Unknown ( ' + reader.status + ' )';
}
}
getStripeReaders();
StripeModule.paymentIntents.getPaymentIntent(props.order_id);
// Check if the readers are available every 5 seconds
setInterval(() => {
if (isReady.value) {
getStripeReaders();
}
}, 5000);
// Reload the payment intent every 5 seconds
setInterval(() => {
if (isReady.value) {
StripeModule.paymentIntents.getPaymentIntent(props.order_id);
}
}, 5000);
const loadingDeleteButton = ref(false);
const setDeleteButtonLoading = (isLoading) => {
console.log('Loading delete button:', isLoading);
loadingDeleteButton.value = !!isLoading;
}
const onClickCancelPaymentIntent = () => {
console.log('Delete payment intent');
loadingDeleteButton.value = true;
StripeModule.paymentIntents.deletePaymentIntent(props.order_id)
.then(async (response) => {
if (response.status === 200) {
console.log('Payment intent deleted successfully:', response.data.data);
// Unset the payment intent
StripeModule.paymentIntents.paymentIntent.value = null;
// Reload the payment intent
StripeModule.paymentIntents.getPaymentIntent(props.order_id).finally(() => {
getStripeReaders().finally(() => {
setDeleteButtonLoading(false);
})
});
} else {
console.error('Error deleting payment intent:', response);
setDeleteButtonLoading(false);
}
})
.catch((error) => {
console.error('Error deleting payment intent:', error);
setDeleteButtonLoading(false);
});
}
</script>
<template>
<div class="columns">
<div class="column">
<!-- Error state -->
<template v-if="error">
<button class="button is-danger" @click="getAvailableReaders">
<span class="icon is-small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>{{ error }}</span>
</button>
</template>
<!-- Ready state, no readers -->
<template v-else-if="readers.length === 0 && isReady">
<button class="button is-warning" @click="getAvailableReaders">
<span class="icon is-small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>No readers available</span>
</button>
</template>
<!-- Ready state, readers available -->
<template v-else-if="readers.length > 0 && isReady && !isLoading && StripeModule.paymentIntents.paymentIntent.value === null">
<button
class="button"
@click="onClick"
:disabled="!isReaderSelected"
:style="{ 'background-color': Colors.buttons.success.backgroundColor, 'color': Colors.buttons.success.textColor }"
>
<span class="icon is-small">
<i class="fas fa-credit-card"></i>
</span>
<span>{{ props.label }}</span>
</button>
</template>
<!-- Currently processing payment -->
<template v-else-if="StripeModule.paymentIntents.paymentIntent.value !== null">
<button class="button is-info" :class="{ 'is-loading': isLoading }">
<span class="icon is-small">
<i class="fas fa-credit-card"></i>
</span>
<span>Processing payment...</span>
</button>
</template>
<!-- Default state, loading -->
<template v-else-if="isLoading">
<button class="button is-loading">
<span class="icon is-small">
<i class="fas fa-spinner"></i>
</span>
<span>Loading...</span>
</button>
</template>
</div>
<!-- Reader selection -->
<div class="column">
<template v-if="isReady && readers.length > 0 && StripeModule.paymentIntents.paymentIntent.value === null">
<div class="select">
<select v-model="selectedReader">
<option disabled value="">
<template v-if="isAnyReadersAvailable()">Select a reader</template>
<template v-else>No available readers</template>
</option>
<template v-for="reader of getAvailableReaders()" :key="reader.id">
<option :value="reader" v-if="isReaderAvailable(reader)">
{{ reader.label }} ({{ getReaderStatus(reader) }})
</option>
</template>
<!-- Unavailable readers -->
<template v-if="isAnyReadersUnavailable()">
<option disabled>Unavailable Readers</option>
<template v-for="reader of readers" :key="reader.id">
<option :value="reader" v-if="!isReaderAvailable(reader)" :disabled="true">
{{ reader.label }} ({{ getReaderStatus(reader) }})
</option>
</template>
</template>
</select>
</div>
</template>
<!-- Currently processing payment -->
<template v-else-if="StripeModule.paymentIntents.paymentIntent.value !== null">
</template>
<!-- No readers available -->
<template v-else-if="readers.length === 0 && isReady">
<div class="select">
<select disabled>
<option>No readers available</option>
</select>
</div>
</template>
</div>
<!-- Select tax percentage -->
<template v-if="StripeModule.paymentIntents.paymentIntent.value === null">
<div class="column">
<div class="select">
<select v-model="selectedTaxRate">
<template v-for="taxRate in tax_rates" :key="taxRate.id">
<option :value="taxRate.id">
{{ taxRate.display_name }} ({{ taxRate.percentage }}% moms)
</option>
</template>
</select>
</div>
</div>
</template>
<!-- If the payment intent exists, show the delete button -->
<div class="column">
<template v-if="StripeModule.paymentIntents.paymentIntent.value !== null">
<button
class="button is-danger"
:class="{ 'is-loading': loadingDeleteButton }"
@click="onClickCancelPaymentIntent()"
>
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>
<span>Delete Payment Intent</span>
</button>
</template>
</div>
{{ error }}
</div>
</template>
<style scoped>
</style>
@@ -5,7 +5,7 @@ import PosLastScannedLicensePlates from "@/components/displays/department/pos/Po
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
import SelectProductsFormPOS from "@/components/forms/department/pos/SelectProductsFormPOS.vue";
import NextStepError from "@/components/forms/department/pos/error/NextStepError.vue";
import { order_items, order_id, loadOrderItems, isCustomerSelected, doesOrderContainMaterial } from "@/components/shop/POSDepartmentProcess.vue";
import { order_items, order_id, loadOrderItems, isCustomerSelected, doesOrderContainMaterial, customer_id, department_id } from "@/components/shop/POSDepartmentProcess.vue";
import PosOrderItemsCurrent from "@/components/displays/department/pos/PosOrderItemsCurrent.vue";
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
import Cancel from "@/components/forms/department/pos/buttons/Cancel.vue";
@@ -13,6 +13,7 @@ import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ButtonsBox from "@/components/displays/boxes/ButtonsBox.vue";
import PreviousStep from "@/components/forms/department/pos/buttons/PreviousStep.vue";
import PosOrderLicensePlates from "@/components/displays/department/pos/order/PosOrderLicensePlates.vue";
import PayWithStripeButton from "@/components/displays/department/pos/displays/PayWithStripeButton.vue";
</script>
@@ -45,11 +46,23 @@ import PosOrderLicensePlates from "@/components/displays/department/pos/order/Po
class="is-fullwidth"
label="Annuller"
/>
<NextStep
style="width: 25%;"
class="is-fullwidth"
label="Gennemfør"
/>
<template v-if="customer_id !== 999">
<NextStep
style="width: 25%;"
class="is-fullwidth"
label="Gennemfør"
/>
</template>
<!-- Stripe customer -->
<template v-else>
<PayWithStripeButton
style="width: 25%;"
class="is-fullwidth"
label="Betal med Stripe"
:departmentId="department_id"
:order_id="order_id"
/>
</template>
</div>
</ButtonsBox>
</div>
@@ -102,6 +102,7 @@ const getDisplayProductAddons = (product) => {
<th>{{ SessionUser.objects.products.columns.name.label }}</th>
<th>{{ SessionUser.objects.products.columns.description.label }}</th>
<th>{{ SessionUser.objects.products.columns.price.label }}</th>
<th>{{ SessionUser.objects.products.columns.subscription_allowed.label }}</th>
<th>{{ SessionUser.objects.products.columns.category.label }}</th>
<th>{{ SessionUser.objects.products.columns.piktogram.label }}</th>
<th>{{ SessionUser.objects.products.columns.economic_product_id.label }}</th>
@@ -135,6 +136,14 @@ const getDisplayProductAddons = (product) => {
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="price"
/>
<!-- Subscription allowed -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="subscription_allowed"
:parseFunction="value => value ? SessionUser.objects.global.language.yes : SessionUser.objects.global.language.no"
/>
<!-- Category -->
<EditableTableColumn
:object="product"
@@ -74,6 +74,14 @@ export const Products = {
required: true
}
},
subscription_allowed: {
label: "Abonnement tilladt",
type: "boolean",
sortable: true,
creation: {
required: false
}
},
category: {
label: "Kategori",
type: "select",
@@ -156,6 +164,14 @@ export const Products = {
ObjectsGlobal.parse.number(price)
)
},
subscription_allowed: async (id, subscription_allowed) => {
return ObjectsGlobal.set.column(
Products.meta.endpoint,
id,
"subscription_allowed",
ObjectsGlobal.parse.boolean(subscription_allowed)
)
},
category: async (id, category) => {
return ObjectsGlobal.set.column(
Products.meta.endpoint,
+9
View File
@@ -0,0 +1,9 @@
<script>
import { ref } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { StripePaymentIntent } from "@/components/stripe/endpoints/PaymentIntents.vue";
export const StripeModule = {
paymentIntents: StripePaymentIntent,
}
</script>
@@ -0,0 +1,86 @@
<script>
import { ref } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { StripeModule } from "@/components/stripe/StripeModule.vue";
const paymentIntent = ref(null);
// Create a payment intent
const createPaymentIntent = async (reader, order_id, tax_percentage = 0) => {
SessionUser.request(
'/orders/module/stripe/payment_intent',
'POST',
{
id: order_id,
reader: reader.id,
tax_percentage: tax_percentage,
},
).then((response) => {
console.log('Response from Stripe:', response.data.data);
if (response.status === 200) {
// Get the payment intent
StripeModule.paymentIntents.getPaymentIntent(order_id);
return response;
} else {
console.error('Error creating payment intent:', response);
return null;
}
}).catch((error) => {
console.error('Error creating payment intent:', error);
})
}
// Get the payment intent
const getPaymentIntent = async (order_id) => {
SessionUser.request(
'/orders/module/stripe/payment_intent',
'GET',
{
id: order_id,
},
).then((response) => {
console.log('Response from Stripe:', response.data.data);
if (response.status === 200) {
paymentIntent.value = response.data.data;
return response;
} else {
console.error('Error getting payment intent:', response);
return null;
}
}).catch((error) => {
console.error('Error getting payment intent:', error);
// Unset the payment intent if an error occurs
paymentIntent.value = null;
})
}
// Delete the payment intent
const deletePaymentIntent = async (order_id) => {
SessionUser.request(
'/orders/module/stripe/payment_intent',
'DELETE',
{
id: order_id,
},
).then((response) => {
console.log('Response from Stripe:', response.data.data);
if (response.status === 200) {
paymentIntent.value = null;
return response;
} else {
console.error('Error deleting payment intent:', response);
return null;
}
}).catch((error) => {
console.error('Error deleting payment intent:', error);
})
}
export const StripePaymentIntent = {
createPaymentIntent,
getPaymentIntent,
deletePaymentIntent,
paymentIntent,
}
</script>
@@ -133,8 +133,13 @@ const isStripeInvoicePaid = () => {
// Load the order, when the page is loaded
loadOrder();
const forceAllowEdit = ref(false);
// Make sure the order isn't invoiced, and the user has permission to edit the order items
const isEditPossible = () => {
if (forceAllowEdit.value) {
return true;
}
return !isInvoicedWithEconomic() && !isInvoicedWithStripe() && SessionUser.hasPermission('edit_order_items') && !closed_at.value;
};
@@ -271,12 +276,18 @@ const isCompleted = () => {
<!-- Other slot -->
<template #other>
<!-- Super user actions -->
<span v-if="SessionUser.canAccessSuperUser()">
<span v-if="SessionUser.canAccessSuperUser()" class="buttons">
<!-- Delete order button -->
<button class="button is-danger is-inverted" @click="showDeleteOrderDialog" v-if="(!isInvoiced() || SessionUser.canAccessSuperUser())">
<span class="icon"><i class="fas fa-trash"></i></span>
<span>Slet ordre</span>
</button>
<!-- Force allow edit button -->
<button class="button is-danger is-inverted" @click="forceAllowEdit = !forceAllowEdit" v-if="!isInvoiced()">
<span class="icon"><i class="fas fa-lock-open"></i></span>
<span v-if="!isEditPossible()">Gennemtving redigeringsret</span>
<span v-else>Fjern tvungen redigeringsret</span>
</button>
</span>
</template>
<!-- Details slot -->
@@ -19,6 +19,8 @@ import UserOtherVaskeabonnement
import OrdersPagination from "@/components/displays/pagination/models/DepartmentPos/OrdersPagination.vue";
import CollectedOrderInvoiceCustomerNotes
from "@/views/dashboards/superUserDashboard/collectedOrderInvoice/displays/collectedOrderInvoiceCustomerNotes.vue";
import InvoiceOrdersPagination
from "@/components/displays/pagination/models/SuperUserDashboard/InvoiceOrdersPagination.vue";
// Get the id from the URL
const router = useRouter();
@@ -89,7 +91,8 @@ getCollectedOrderInvoice();
v-bind:collectedOrderInvoice="collectedOrderInvoice"
/>
<!-- Orders -->
<OrdersPagination :auto-load="true" :onlyFromInvoiceCollection="collectedOrderInvoiceId"/>
<InvoiceOrdersPagination :auto-load="true" :onlyFromInvoiceCollection="collectedOrderInvoiceId" :invoice-view="true"/>
<!-- <OrdersPagination :auto-load="true" :onlyFromInvoiceCollection="collectedOrderInvoiceId" /> -->
</template>
</NotFoundFallBackPageWrapper>
</RestrictedPageWrapper>