Remove manual Stripe payment actions (#233)
## Summary - remove hosted email-payment/payment-link creation from POS and order management - automatically capture authorized card payments instead of exposing a manual capture action - replace provider-specific Stripe wording with card-terminal terminology - retain ordinary terminal payment, receipts, order completion, and navigation - add focused unit and Playwright regression coverage Paired API change: https://github.com/copenhagentruckwash/api/pull/327 ## Verification - focused frontend unit tests: 8/8 passed - desktop card-payment Playwright: 4/4 passed - mobile card-payment Playwright: 15/15 passed - admin order action rail Playwright: 3/3 passed - ESLint, translation generation/checks, build, and diff checks passed - all 18 visual states passed their relevant DOM assertions ## Visual change previews ### View: Regular POS card-payment view **Description:** The hosted email-payment action is removed and provider-specific wording becomes generic card-terminal wording. #### Mobile **Before:**  **After:**  #### Tablet **Before:**  **After:**  #### Desktop **Before:**  **After:**  ### View: Authorized payment capture **Description:** The manual capture action is replaced by an automatically initiated capture and its in-progress state. #### Mobile **Before:**  **After:**  #### Tablet **Before:**  **After:**  #### Desktop **Before:**  **After:**  ### View: Order-dashboard action rail **Description:** The hosted Stripe invoice/payment-link action is removed while normal order actions remain. #### Mobile **Before:**  **After:**  #### Tablet **Before:**  **After:**  #### Desktop **Before:**  **After:**  ## Residual risk No live Stripe Terminal hardware or production Stripe account was used. Browser behavior is verified with repository-owned mocks; the API suite verifies route and payment-intent lifecycle wiring.
@@ -0,0 +1,48 @@
|
||||
# Stripe cleanup visual evidence
|
||||
|
||||
Authentic browser captures compare `origin/master` at
|
||||
`fd31609cb379cda36fb16ef7077fc9db3c91eb2b` with the feature at
|
||||
`6e795b253062606e6122cc7e630e17651b9b7efd`.
|
||||
|
||||
Both revisions were rendered by their own Vite applications and exercised with
|
||||
the repository's mocked Playwright API support. No production API, Stripe
|
||||
account, or product-source modification was used to create the evidence.
|
||||
|
||||
## Regular POS card-payment view
|
||||
|
||||
The baseline identifies the integration as Stripe and offers an email payment.
|
||||
The feature uses provider-neutral card-terminal wording and removes the hosted
|
||||
email-payment action while preserving terminal payment.
|
||||
|
||||
| Device | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Mobile | [Before](before-pos-card-payment-mobile.png) | [After](after-pos-card-payment-mobile.png) |
|
||||
| Tablet | [Before](before-pos-card-payment-tablet.png) | [After](after-pos-card-payment-tablet.png) |
|
||||
| Desktop | [Before](before-pos-card-payment-desktop.png) | [After](after-pos-card-payment-desktop.png) |
|
||||
|
||||
## Authorized payment capture
|
||||
|
||||
Both revisions receive a mocked payment intent in `requires_capture` state. The
|
||||
baseline exposes a manual capture action. The feature automatically issues the
|
||||
capture request and shows its in-progress state without a second manual action.
|
||||
|
||||
| Device | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Mobile | [Before](before-payment-capture-mobile.png) | [After](after-payment-capture-mobile.png) |
|
||||
| Tablet | [Before](before-payment-capture-tablet.png) | [After](after-payment-capture-tablet.png) |
|
||||
| Desktop | [Before](before-payment-capture-desktop.png) | [After](after-payment-capture-desktop.png) |
|
||||
|
||||
## Order-dashboard action rail
|
||||
|
||||
The baseline action rail includes the hosted Stripe invoice/payment-link
|
||||
action. The feature removes it while preserving receipts, ordinary order
|
||||
completion, and navigation.
|
||||
|
||||
| Device | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Mobile | [Before](before-order-dashboard-mobile.png) | [After](after-order-dashboard-mobile.png) |
|
||||
| Tablet | [Before](before-order-dashboard-tablet.png) | [After](after-order-dashboard-tablet.png) |
|
||||
| Desktop | [Before](before-order-dashboard-desktop.png) | [After](after-order-dashboard-desktop.png) |
|
||||
|
||||
All nine paired states passed their relevant DOM assertions across Chromium
|
||||
mobile, tablet, and desktop projects.
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 70 KiB |
@@ -6,20 +6,16 @@ import { StripeModule } from "@/components/stripe/StripeModule.vue";
|
||||
import { Colors } from "@/ThemeConfig.vue";
|
||||
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
|
||||
import PrintInvoiceFromOrderItems from "@/components/forms/department/pos/buttons/PrintInvoiceFromOrderItems.vue";
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import StripeTerminalReaderSelect from "@/components/displays/department/pos/displays/StripeTerminalReaderSelect.vue";
|
||||
import { getOrder } from "@/components/shop/Orders.vue";
|
||||
import {
|
||||
getStripeTerminalReaderById,
|
||||
normalizeStripeTerminalReaders,
|
||||
selectPreferredStripeTerminalReaderId,
|
||||
STRIPE_TERMINAL_STATUS,
|
||||
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
|
||||
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
|
||||
|
||||
const POLLING_INTERVAL_MS = 5000;
|
||||
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
|
||||
const STRIPE_EMAIL_POLLING_INTERVAL_OVERRIDE_KEY = '__TW_POS_STRIPE_EMAIL_POLLING_INTERVAL_MS__';
|
||||
|
||||
const props = defineProps({
|
||||
departmentId: {
|
||||
@@ -50,16 +46,6 @@ const props = defineProps({
|
||||
|
||||
const { t } = useI18n();
|
||||
const stripeText = (key, params = {}) => t(`admin.pos.stripe.${key}`, params);
|
||||
const getStripeEmailPollingIntervalMs = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return POLLING_INTERVAL_MS;
|
||||
}
|
||||
|
||||
const override = Number(window[STRIPE_EMAIL_POLLING_INTERVAL_OVERRIDE_KEY]);
|
||||
return Number.isFinite(override) && override > 0
|
||||
? override
|
||||
: POLLING_INTERVAL_MS;
|
||||
};
|
||||
|
||||
const readers = ref([]);
|
||||
const readersError = ref(null);
|
||||
@@ -71,13 +57,9 @@ const selectedReaderId = ref('');
|
||||
const loadingDeleteButton = ref(false);
|
||||
const isNotifyingPaymentSuccess = ref(false);
|
||||
const notifiedPaymentIntentId = ref(null);
|
||||
const autoCapturePaymentIntentId = ref(null);
|
||||
const operationState = ref('idle');
|
||||
const pollingEnabled = ref(true);
|
||||
const stripeInvoice = ref(null);
|
||||
const emailAddress = ref('');
|
||||
const emailPanelState = ref('idle');
|
||||
const emailPaymentError = ref(null);
|
||||
const isCompletingPaidEmailOrder = ref(false);
|
||||
|
||||
const taxRates = computed(() => ([
|
||||
{ id: 1, display_name: stripeText('tax_rates.danish_customer'), percentage: 25 },
|
||||
@@ -87,213 +69,9 @@ const taxRates = computed(() => ([
|
||||
const selectedTaxRate = ref(1);
|
||||
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
|
||||
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
|
||||
|
||||
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
|
||||
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
|
||||
const isStripeEmailInvoiceTerminalState = computed(() => {
|
||||
const status = String(stripeInvoice.value?.status ?? '');
|
||||
return ['paid', 'void', 'uncollectible', 'deleted'].includes(status);
|
||||
});
|
||||
const isEmailFormVisible = computed(() => {
|
||||
return ['composing', 'sending'].includes(emailPanelState.value) && !hasStripeEmailInvoice.value;
|
||||
});
|
||||
const isSendingEmailPayment = computed(() => emailPanelState.value === 'sending');
|
||||
const isCancellingEmailPayment = computed(() => emailPanelState.value === 'cancelling');
|
||||
const shouldShowEmailActionButton = computed(() => emailPanelState.value === 'idle' && !hasStripeEmailInvoice.value);
|
||||
const canCancelEmailPayment = computed(() => {
|
||||
if (isSendingEmailPayment.value || isCancellingEmailPayment.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasStripeEmailInvoice.value) {
|
||||
return !isStripeEmailInvoicePaid.value && !isStripeEmailInvoiceTerminalState.value;
|
||||
}
|
||||
|
||||
return emailPanelState.value === 'composing';
|
||||
});
|
||||
const canCompletePaidEmailOrder = computed(() => {
|
||||
return props.mobileMode && isStripeEmailInvoicePaid.value && typeof props.onPaymentSuccess === 'function';
|
||||
});
|
||||
const stripeEmailStatusToneClass = computed(() => {
|
||||
switch (String(stripeInvoice.value?.status ?? 'unknown')) {
|
||||
case 'paid':
|
||||
return 'is-success';
|
||||
case 'open':
|
||||
case 'draft':
|
||||
return 'is-warning';
|
||||
case 'void':
|
||||
case 'uncollectible':
|
||||
case 'deleted':
|
||||
return 'is-light';
|
||||
default:
|
||||
return 'is-light';
|
||||
}
|
||||
});
|
||||
const stripeEmailStatusMessage = computed(() => {
|
||||
if (!hasStripeEmailInvoice.value) {
|
||||
return stripeText('email.help');
|
||||
}
|
||||
|
||||
if (isStripeEmailInvoicePaid.value) {
|
||||
return stripeText('email.messages.paid');
|
||||
}
|
||||
|
||||
if (isStripeEmailInvoiceTerminalState.value) {
|
||||
return stripeText('email.messages.closed');
|
||||
}
|
||||
|
||||
return stripeText('email.messages.sent');
|
||||
});
|
||||
const shouldShowDesktopCompletion = computed(() => {
|
||||
return isTerminalPaymentCaptured.value || isStripeEmailInvoicePaid.value;
|
||||
});
|
||||
const shouldShowDesktopCompletion = computed(() => isTerminalPaymentCaptured.value);
|
||||
const shouldShowDesktopReceipt = computed(() => isTerminalPaymentCaptured.value);
|
||||
|
||||
const getStripeEmailStatusLabel = () => {
|
||||
switch (String(stripeInvoice.value?.status ?? 'unknown')) {
|
||||
case 'open':
|
||||
return stripeText('email.states.open');
|
||||
case 'paid':
|
||||
return stripeText('email.states.paid');
|
||||
case 'void':
|
||||
return stripeText('email.states.void');
|
||||
case 'uncollectible':
|
||||
return stripeText('email.states.uncollectible');
|
||||
case 'deleted':
|
||||
return stripeText('email.states.deleted');
|
||||
case 'draft':
|
||||
return stripeText('email.states.draft');
|
||||
default:
|
||||
return stripeText('email.states.unknown');
|
||||
}
|
||||
};
|
||||
|
||||
const isValidEmailAddress = (value) => {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value ?? '').trim());
|
||||
};
|
||||
|
||||
const loadStripeInvoiceState = async () => {
|
||||
if (!props.order_id) {
|
||||
stripeInvoice.value = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getOrder(props.order_id, true);
|
||||
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id);
|
||||
stripeInvoice.value = nextInvoice;
|
||||
if (nextInvoice) {
|
||||
emailPanelState.value = 'tracking';
|
||||
} else if (emailPanelState.value === 'tracking') {
|
||||
emailPanelState.value = 'idle';
|
||||
}
|
||||
emailPaymentError.value = null;
|
||||
return nextInvoice;
|
||||
} catch (requestError) {
|
||||
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.load');
|
||||
return stripeInvoice.value;
|
||||
}
|
||||
};
|
||||
|
||||
const openEmailPaymentPanel = () => {
|
||||
emailPaymentError.value = null;
|
||||
emailPanelState.value = 'composing';
|
||||
};
|
||||
|
||||
const resetEmailPaymentPanel = () => {
|
||||
stripeInvoice.value = null;
|
||||
emailAddress.value = '';
|
||||
emailPaymentError.value = null;
|
||||
emailPanelState.value = 'idle';
|
||||
isCompletingPaidEmailOrder.value = false;
|
||||
};
|
||||
|
||||
const cancelEmailPayment = async () => {
|
||||
if (!hasStripeEmailInvoice.value) {
|
||||
emailAddress.value = '';
|
||||
emailPaymentError.value = null;
|
||||
emailPanelState.value = 'idle';
|
||||
return;
|
||||
}
|
||||
|
||||
emailPanelState.value = 'cancelling';
|
||||
emailPaymentError.value = null;
|
||||
|
||||
try {
|
||||
await authenticatedRequest('/modules/stripe/invoice', 'DELETE', {
|
||||
order_id: props.order_id,
|
||||
});
|
||||
resetEmailPaymentPanel();
|
||||
} catch (requestError) {
|
||||
const responseData = requestError?.response?.data?.data || {};
|
||||
stripeInvoice.value = normalizeStripeInvoice(responseData?.stripeModuleOrders) || stripeInvoice.value;
|
||||
emailPanelState.value = 'tracking';
|
||||
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.cancel');
|
||||
}
|
||||
};
|
||||
|
||||
const sendStripeInvoiceByEmail = async () => {
|
||||
const normalizedEmail = String(emailAddress.value ?? '').trim();
|
||||
if (!isValidEmailAddress(normalizedEmail)) {
|
||||
emailPaymentError.value = stripeText('email.errors.invalid_email');
|
||||
emailPanelState.value = 'composing';
|
||||
return;
|
||||
}
|
||||
|
||||
emailPanelState.value = 'sending';
|
||||
emailPaymentError.value = null;
|
||||
|
||||
try {
|
||||
const response = await authenticatedRequest('/modules/stripe/invoice', 'POST', {
|
||||
email: normalizedEmail,
|
||||
order_id: props.order_id,
|
||||
});
|
||||
|
||||
emailAddress.value = '';
|
||||
const refreshedInvoice = await loadStripeInvoiceState();
|
||||
if (!refreshedInvoice) {
|
||||
stripeInvoice.value = normalizeStripeInvoice({
|
||||
id: props.order_id,
|
||||
invoice_id: response?.data?.data?.id ?? null,
|
||||
customer_id: response?.data?.data?.customer ?? null,
|
||||
url: response?.data?.data?.hosted_invoice_url ?? null,
|
||||
paid: Boolean(response?.data?.data?.paid),
|
||||
status: response?.data?.data?.status ?? 'open',
|
||||
amount_due: response?.data?.data?.amount_due ?? 0,
|
||||
amount_paid: response?.data?.data?.amount_paid ?? 0,
|
||||
});
|
||||
emailPanelState.value = stripeInvoice.value ? 'tracking' : 'idle';
|
||||
}
|
||||
} catch (requestError) {
|
||||
const responseData = requestError?.response?.data?.data || {};
|
||||
if (requestError?.response?.status === 409 && responseData?.code === 'stripe_invoice_exists') {
|
||||
stripeInvoice.value = normalizeStripeInvoice(responseData?.stripeModuleOrders) || await loadStripeInvoiceState();
|
||||
emailPanelState.value = stripeInvoice.value ? 'tracking' : 'idle';
|
||||
emailPaymentError.value = stripeText('email.messages.active');
|
||||
return;
|
||||
}
|
||||
|
||||
emailPanelState.value = 'composing';
|
||||
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.send');
|
||||
}
|
||||
};
|
||||
|
||||
const completePaidEmailOrder = async () => {
|
||||
if (!canCompletePaidEmailOrder.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isCompletingPaidEmailOrder.value = true;
|
||||
emailPaymentError.value = null;
|
||||
try {
|
||||
await Promise.resolve(props.onPaymentSuccess(stripeInvoice.value));
|
||||
} catch (requestError) {
|
||||
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.complete');
|
||||
} finally {
|
||||
isCompletingPaidEmailOrder.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeErrorState = (errorLike, fallbackMessage, source) => {
|
||||
const responsePayload = errorLike?.response?.data?.data;
|
||||
const directPayload = errorLike?.data?.data ?? errorLike?.data;
|
||||
@@ -519,9 +297,7 @@ const primaryActionLabel = computed(() => {
|
||||
case 'waiting_for_reader':
|
||||
return stripeText('actions.refresh_payment_status');
|
||||
case 'ready_to_capture':
|
||||
return paymentAmountSummary.value
|
||||
? stripeText('actions.capture_payment_with_amount', { amount: paymentAmountSummary.value })
|
||||
: stripeText('actions.capture_payment');
|
||||
return stripeText('states.capturing');
|
||||
case 'succeeded':
|
||||
return stripeText('actions.show_payment_details');
|
||||
case 'failed':
|
||||
@@ -542,9 +318,8 @@ const primaryActionTestId = computed(() => {
|
||||
case 'reader_unavailable':
|
||||
return 'pos-stripe-no-readers';
|
||||
case 'waiting_for_reader':
|
||||
return 'pos-stripe-payment-in-progress';
|
||||
case 'ready_to_capture':
|
||||
return 'pos-stripe-capture-intent';
|
||||
return 'pos-stripe-payment-in-progress';
|
||||
case 'succeeded':
|
||||
return 'pos-stripe-payment-succeeded';
|
||||
case 'failed':
|
||||
@@ -683,6 +458,7 @@ const onClickCapturePaymentIntent = async () => {
|
||||
try {
|
||||
const response = await StripeModule.paymentIntents.capturePaymentIntent(props.order_id);
|
||||
if (response?.status !== 200) {
|
||||
autoCapturePaymentIntentId.value = null;
|
||||
actionError.value = normalizeErrorState(response, stripeText('errors.capture_payment_intent'), 'action');
|
||||
return;
|
||||
}
|
||||
@@ -690,6 +466,7 @@ const onClickCapturePaymentIntent = async () => {
|
||||
clearErrorState('payment');
|
||||
await getStripeReaders();
|
||||
} catch (requestError) {
|
||||
autoCapturePaymentIntentId.value = null;
|
||||
actionError.value = normalizeErrorState(requestError, stripeText('errors.capture_payment_intent'), 'action');
|
||||
console.error('Error capturing payment intent:', requestError);
|
||||
} finally {
|
||||
@@ -755,7 +532,6 @@ const onPrimaryAction = async () => {
|
||||
await retryLoadState();
|
||||
return;
|
||||
case 'ready_to_capture':
|
||||
await onClickCapturePaymentIntent();
|
||||
return;
|
||||
case 'succeeded':
|
||||
showPaymentDetails(paymentIntent.value);
|
||||
@@ -796,26 +572,38 @@ const maybeNotifyPaymentSuccess = async (stripePaymentIntent) => {
|
||||
watch(paymentIntent, (nextPaymentIntent) => {
|
||||
if (!nextPaymentIntent) {
|
||||
notifiedPaymentIntentId.value = null;
|
||||
autoCapturePaymentIntentId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
maybeNotifyPaymentSuccess(nextPaymentIntent);
|
||||
}, { deep: true });
|
||||
|
||||
watch([currentIntentState, operationState, currentError], ([nextState]) => {
|
||||
if (
|
||||
nextState !== 'ready_to_capture'
|
||||
|| operationState.value !== 'idle'
|
||||
|| currentError.value
|
||||
|| !paymentIntent.value?.id
|
||||
|| autoCapturePaymentIntentId.value === paymentIntent.value.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoCapturePaymentIntentId.value = paymentIntent.value.id;
|
||||
onClickCapturePaymentIntent();
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.order_id, async () => {
|
||||
clearErrorState('readers', 'payment', 'action');
|
||||
operationState.value = 'idle';
|
||||
pollingEnabled.value = true;
|
||||
notifiedPaymentIntentId.value = null;
|
||||
resetEmailPaymentPanel();
|
||||
await Promise.all([
|
||||
syncCurrentState(),
|
||||
loadStripeInvoiceState(),
|
||||
]);
|
||||
autoCapturePaymentIntentId.value = null;
|
||||
await syncCurrentState();
|
||||
});
|
||||
|
||||
let pollingIntervalId = null;
|
||||
let emailPollingIntervalId = null;
|
||||
|
||||
const clearPollingInterval = () => {
|
||||
if (pollingIntervalId !== null) {
|
||||
@@ -824,13 +612,6 @@ const clearPollingInterval = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearEmailPollingInterval = () => {
|
||||
if (emailPollingIntervalId !== null) {
|
||||
window.clearInterval(emailPollingIntervalId);
|
||||
emailPollingIntervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const shouldPoll = computed(() => {
|
||||
return pollingEnabled.value
|
||||
&& paymentFlowState.value !== 'succeeded'
|
||||
@@ -854,25 +635,6 @@ watch(shouldPoll, (nextShouldPoll) => {
|
||||
}, POLLING_INTERVAL_MS);
|
||||
}, { immediate: true });
|
||||
|
||||
const shouldPollStripeEmailInvoice = computed(() => {
|
||||
return hasStripeEmailInvoice.value
|
||||
&& emailPanelState.value === 'tracking'
|
||||
&& !isStripeEmailInvoiceTerminalState.value
|
||||
&& !isCancellingEmailPayment.value;
|
||||
});
|
||||
|
||||
watch(shouldPollStripeEmailInvoice, (nextShouldPoll) => {
|
||||
clearEmailPollingInterval();
|
||||
|
||||
if (!nextShouldPoll) {
|
||||
return;
|
||||
}
|
||||
|
||||
emailPollingIntervalId = window.setInterval(() => {
|
||||
loadStripeInvoiceState();
|
||||
}, getStripeEmailPollingIntervalMs());
|
||||
}, { immediate: true });
|
||||
|
||||
watch(paymentFlowState, (nextState) => {
|
||||
if (nextState === 'succeeded') {
|
||||
pollingEnabled.value = false;
|
||||
@@ -882,15 +644,11 @@ watch(paymentFlowState, (nextState) => {
|
||||
|
||||
onMounted(async () => {
|
||||
pollingEnabled.value = true;
|
||||
await Promise.all([
|
||||
syncCurrentState(),
|
||||
loadStripeInvoiceState(),
|
||||
]);
|
||||
await syncCurrentState();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearPollingInterval();
|
||||
clearEmailPollingInterval();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -977,123 +735,6 @@ onUnmounted(() => {
|
||||
{{ stripeText('actions.cancel_payment') }}
|
||||
</button>
|
||||
|
||||
<div class="mobile-stripe-payment__email">
|
||||
<div class="pos-stripe-email-panel" data-testid="pos-stripe-email-panel">
|
||||
<button
|
||||
v-if="shouldShowEmailActionButton"
|
||||
class="button is-fullwidth pos-stripe-email-panel__trigger"
|
||||
type="button"
|
||||
data-testid="pos-stripe-email-action"
|
||||
@click="openEmailPaymentPanel"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</span>
|
||||
<span>{{ stripeText('email.action') }}</span>
|
||||
</button>
|
||||
|
||||
<div v-else class="pos-stripe-email-panel__body">
|
||||
<div class="pos-stripe-email-panel__header">
|
||||
<div>
|
||||
<p class="pos-stripe-email-panel__eyebrow">{{ stripeText('email.eyebrow') }}</p>
|
||||
<p class="pos-stripe-email-panel__title">{{ stripeText('email.title') }}</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="hasStripeEmailInvoice"
|
||||
class="tag pos-stripe-email-panel__status-tag"
|
||||
:class="stripeEmailStatusToneClass"
|
||||
data-testid="pos-stripe-email-status"
|
||||
>
|
||||
{{ getStripeEmailStatusLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="pos-stripe-email-panel__help">
|
||||
{{ stripeEmailStatusMessage }}
|
||||
</p>
|
||||
|
||||
<div v-if="isEmailFormVisible" class="pos-stripe-email-panel__form">
|
||||
<label class="label is-size-7" for="pos-stripe-email-input-mobile">
|
||||
{{ stripeText('email.address_label') }}
|
||||
</label>
|
||||
<div class="field has-addons pos-stripe-email-panel__field">
|
||||
<div class="control is-expanded">
|
||||
<input
|
||||
id="pos-stripe-email-input-mobile"
|
||||
v-model="emailAddress"
|
||||
class="input"
|
||||
type="email"
|
||||
:placeholder="stripeText('email.address_placeholder')"
|
||||
autocomplete="email"
|
||||
data-testid="pos-stripe-email-input"
|
||||
@keydown.enter.prevent="sendStripeInvoiceByEmail"
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button
|
||||
class="button is-link"
|
||||
type="button"
|
||||
data-testid="pos-stripe-email-submit"
|
||||
:class="{ 'is-loading': isSendingEmailPayment }"
|
||||
:disabled="isSendingEmailPayment"
|
||||
@click="sendStripeInvoiceByEmail"
|
||||
>
|
||||
{{ stripeText('email.submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasStripeEmailInvoice" class="pos-stripe-email-panel__meta">
|
||||
<div class="pos-stripe-email-panel__meta-row">
|
||||
<span>{{ stripeText('email.status_label') }}</span>
|
||||
<strong>{{ getStripeEmailStatusLabel() }}</strong>
|
||||
</div>
|
||||
<div v-if="stripeInvoice?.url" class="pos-stripe-email-panel__meta-row">
|
||||
<span>{{ stripeText('email.invoice_link_label') }}</span>
|
||||
<a :href="stripeInvoice.url" target="_blank" rel="noopener noreferrer">
|
||||
{{ stripeText('email.invoice_link_action') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="emailPaymentError"
|
||||
class="help is-danger pos-stripe-email-panel__error"
|
||||
data-testid="pos-stripe-email-error"
|
||||
>
|
||||
{{ emailPaymentError }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="canCancelEmailPayment || canCompletePaidEmailOrder"
|
||||
class="pos-stripe-email-panel__actions"
|
||||
>
|
||||
<button
|
||||
v-if="canCancelEmailPayment"
|
||||
class="button is-light is-fullwidth"
|
||||
type="button"
|
||||
data-testid="pos-stripe-email-cancel"
|
||||
:class="{ 'is-loading': isCancellingEmailPayment }"
|
||||
@click="cancelEmailPayment"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canCompletePaidEmailOrder"
|
||||
class="button is-success is-fullwidth"
|
||||
type="button"
|
||||
data-testid="pos-stripe-email-complete-order"
|
||||
:class="{ 'is-loading': isCompletingPaidEmailOrder }"
|
||||
@click="completePaidEmailOrder"
|
||||
>
|
||||
{{ t('admin.pos.complete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1222,21 +863,6 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="paymentFlowState === 'ready_to_capture'">
|
||||
<button
|
||||
class="button is-fullwidth"
|
||||
type="button"
|
||||
data-testid="pos-stripe-capture-intent"
|
||||
:style="{ 'background-color': Colors.buttons.success.backgroundColor, 'color': Colors.buttons.success.textColor }"
|
||||
@click="onClickCapturePaymentIntent"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-credit-card"></i>
|
||||
</span>
|
||||
<span>{{ primaryActionLabel }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="paymentFlowState === 'succeeded'">
|
||||
<button
|
||||
class="button is-text is-fullwidth pos-stripe-desktop__text-button"
|
||||
@@ -1289,124 +915,6 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div class="pos-stripe-desktop__item">
|
||||
<div class="pos-stripe-email-panel" data-testid="pos-stripe-email-panel">
|
||||
<button
|
||||
v-if="shouldShowEmailActionButton"
|
||||
class="button is-fullwidth pos-stripe-email-panel__trigger"
|
||||
type="button"
|
||||
data-testid="pos-stripe-email-action"
|
||||
@click="openEmailPaymentPanel"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</span>
|
||||
<span>{{ stripeText('email.action') }}</span>
|
||||
</button>
|
||||
|
||||
<div v-else class="pos-stripe-email-panel__body">
|
||||
<div class="pos-stripe-email-panel__header">
|
||||
<div>
|
||||
<p class="pos-stripe-email-panel__eyebrow">{{ stripeText('email.eyebrow') }}</p>
|
||||
<p class="pos-stripe-email-panel__title">{{ stripeText('email.title') }}</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="hasStripeEmailInvoice"
|
||||
class="tag pos-stripe-email-panel__status-tag"
|
||||
:class="stripeEmailStatusToneClass"
|
||||
data-testid="pos-stripe-email-status"
|
||||
>
|
||||
{{ getStripeEmailStatusLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="pos-stripe-email-panel__help">
|
||||
{{ stripeEmailStatusMessage }}
|
||||
</p>
|
||||
|
||||
<div v-if="isEmailFormVisible" class="pos-stripe-email-panel__form">
|
||||
<label class="label is-size-7" for="pos-stripe-email-input-desktop">
|
||||
{{ stripeText('email.address_label') }}
|
||||
</label>
|
||||
<div class="field has-addons pos-stripe-email-panel__field">
|
||||
<div class="control is-expanded">
|
||||
<input
|
||||
id="pos-stripe-email-input-desktop"
|
||||
v-model="emailAddress"
|
||||
class="input"
|
||||
type="email"
|
||||
:placeholder="stripeText('email.address_placeholder')"
|
||||
autocomplete="email"
|
||||
data-testid="pos-stripe-email-input"
|
||||
@keydown.enter.prevent="sendStripeInvoiceByEmail"
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button
|
||||
class="button is-link"
|
||||
type="button"
|
||||
data-testid="pos-stripe-email-submit"
|
||||
:class="{ 'is-loading': isSendingEmailPayment }"
|
||||
:disabled="isSendingEmailPayment"
|
||||
@click="sendStripeInvoiceByEmail"
|
||||
>
|
||||
{{ stripeText('email.submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasStripeEmailInvoice" class="pos-stripe-email-panel__meta">
|
||||
<div class="pos-stripe-email-panel__meta-row">
|
||||
<span>{{ stripeText('email.status_label') }}</span>
|
||||
<strong>{{ getStripeEmailStatusLabel() }}</strong>
|
||||
</div>
|
||||
<div v-if="stripeInvoice?.url" class="pos-stripe-email-panel__meta-row">
|
||||
<span>{{ stripeText('email.invoice_link_label') }}</span>
|
||||
<a :href="stripeInvoice.url" target="_blank" rel="noopener noreferrer">
|
||||
{{ stripeText('email.invoice_link_action') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="emailPaymentError"
|
||||
class="help is-danger pos-stripe-email-panel__error"
|
||||
data-testid="pos-stripe-email-error"
|
||||
>
|
||||
{{ emailPaymentError }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="canCancelEmailPayment || canCompletePaidEmailOrder"
|
||||
class="pos-stripe-email-panel__actions"
|
||||
>
|
||||
<button
|
||||
v-if="canCancelEmailPayment"
|
||||
class="button is-light is-fullwidth"
|
||||
type="button"
|
||||
data-testid="pos-stripe-email-cancel"
|
||||
:class="{ 'is-loading': isCancellingEmailPayment }"
|
||||
@click="cancelEmailPayment"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canCompletePaidEmailOrder"
|
||||
class="button is-success is-fullwidth"
|
||||
type="button"
|
||||
data-testid="pos-stripe-email-complete-order"
|
||||
:class="{ 'is-loading': isCompletingPaidEmailOrder }"
|
||||
@click="completePaidEmailOrder"
|
||||
>
|
||||
{{ t('admin.pos.complete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="shouldShowDesktopCompletion">
|
||||
<div class="pos-stripe-desktop__item">
|
||||
<NextStep class="is-fullwidth" :label="t('admin.pos.complete')" />
|
||||
@@ -1484,10 +992,6 @@ onUnmounted(() => {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-stripe-payment__email {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.select.is-fullwidth,
|
||||
.select.is-fullwidth select {
|
||||
width: 100%;
|
||||
@@ -1551,92 +1055,4 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__trigger {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__body {
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 0.85rem;
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__title {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__status-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__help {
|
||||
margin: 0.65rem 0 0;
|
||||
color: #475569;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__form,
|
||||
.pos-stripe-email-panel__meta,
|
||||
.pos-stripe-email-panel__actions {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__meta-row span {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__meta-row a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__error {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.pos-stripe-email-panel__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -217,7 +217,7 @@ const onBackClick = async () => {
|
||||
</div>
|
||||
|
||||
<p class="mobile-card-payment-view__description">
|
||||
Use the selected Stripe Terminal reader or send a Stripe payment link by email for this order.
|
||||
Use the selected card terminal for this order.
|
||||
</p>
|
||||
|
||||
<PayWithStripeButton
|
||||
|
||||
@@ -79,7 +79,7 @@ const onSelectCardPayment = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
selectCustomer({name: 'Stripe Payment', customerNumber: 999});
|
||||
selectCustomer({name: 'Kortbetaling', customerNumber: 999});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ const onSelectCardPayment = () => {
|
||||
showSelector.value = false;
|
||||
|
||||
const cardPaymentCustomer = {
|
||||
name: "Stripe Payment",
|
||||
name: "Kortbetaling",
|
||||
customerNumber: 999,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<script setup>
|
||||
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import { handleEconomicError } from "@/components/request/HandleEconomicError.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
const props = defineProps(["order_id"]);
|
||||
|
||||
const exportOrderToInvoiceStripe = async () => {
|
||||
// Get the email to send the invoice to
|
||||
await Swal.fire({
|
||||
title: "Indtast email til faktura",
|
||||
input: "email",
|
||||
inputAttributes: {
|
||||
autocapitalize: "off",
|
||||
},
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Send faktura",
|
||||
showLoaderOnConfirm: true,
|
||||
preConfirm: (email) => {
|
||||
return authenticatedRequest("/modules/stripe/invoice", "POST", {
|
||||
email,
|
||||
order_id: Number.parseInt(String(props.order_id), 10),
|
||||
})
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
Swal.fire({
|
||||
title: "Faktura sendt",
|
||||
icon: "success",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
handleEconomicError(error);
|
||||
});
|
||||
},
|
||||
allowOutsideClick: () => !Swal.isLoading(),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadButtonWhileAwait
|
||||
class="is-success is-inverted"
|
||||
:loadFunction="exportOrderToInvoiceStripe"
|
||||
icon="fas fa-file-invoice"
|
||||
actionKey="economic-invoice-stripe-export-submit"
|
||||
copyKey="complete"
|
||||
>
|
||||
{{ SessionUser.objects.global.language.complete }}
|
||||
</LoadButtonWhileAwait>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1862,7 +1862,7 @@
|
||||
"orders": {
|
||||
"subtitle": "@.capitalize:{'words.generated.administrer'} @:{'words.generated.ordrer'}"
|
||||
},
|
||||
"pay_with_stripe": "@:{'words.generated.betal'} @:{'words.generated.med'} @:{'words.generated.stripe'}",
|
||||
"pay_with_stripe": "Kortbetaling",
|
||||
"po_number": "@:{'words.generated.po'} @.capitalize:{'words.generated.nummer'}",
|
||||
"price_changed_from": "@:{'words.generated.prisen'} @:{'words.generated.er'} @:{'words.generated.ændret'} @:{'words.generated.fra'} {from} @:{'words.generated.dkk'} @:{'words.generated.til'} {to} @:{'words.generated.dkk'} / @:{'words.generated.enhed'} ({total} @:{'words.generated.dkk'} @:{'words.generated.total'})",
|
||||
"recent_scan_details_empty": "@.capitalize:{'words.generated.der'} @:{'words.generated.er'} @:{'words.generated.ingen'} @:{'words.generated.køretøjsdetaljer'} @:{'words.generated.til'} @:{'words.generated.denne'} @:{'words.generated.skanning'}.",
|
||||
@@ -2014,15 +2014,15 @@
|
||||
"create_payment_intent": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.oprette'} @:{'words.generated.payment'} @.capitalize:{'words.generated.intent'}.",
|
||||
"delete_payment_intent": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.slette'} @:{'words.generated.payment'} @.capitalize:{'words.generated.intent'}.",
|
||||
"load_payment_intent_state": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.hente'} @:{'words.generated.betalingsstatus'}.",
|
||||
"load_readers": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.hente'} @:{'words.generated.stripe'}-@:{'words.generated.terminaler'}.",
|
||||
"load_readers": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.hente'} kortbetalingsterminaler.",
|
||||
"select_reader_before_payment": "@.capitalize:{'words.generated.vælg'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.terminal'}, @:{'words.generated.før'} @:{'words.generated.betalingen'} startes."
|
||||
},
|
||||
"headings": {
|
||||
"card_payment": "@:{'words.generated.kortbetaling'}",
|
||||
"terminal": "@:{'words.generated.stripe'} @.capitalize:{'words.generated.terminal'}"
|
||||
"terminal": "Kortbetalingsterminal"
|
||||
},
|
||||
"messages": {
|
||||
"contact_superuser": "@.capitalize:{'words.generated.kontakt'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.superbruger'} @:{'words.generated.for'} @:{'words.generated.at'} åbne @:{'words.generated.stripe'}-opsætningen @:{'words.generated.og'} @:{'words.generated.vælge'} @:{'words.replication.host_definite_suffix'} terminalplacering @:{'words.generated.til'} @:{'words.generated.denne'} @:{'words.generated.afdeling'}.",
|
||||
"contact_superuser": "@.capitalize:{'words.generated.kontakt'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.superbruger'} @:{'words.generated.for'} @:{'words.generated.at'} åbne kortbetalingsopsætningen @:{'words.generated.og'} @:{'words.generated.vælge'} @:{'words.replication.host_definite_suffix'} terminalplacering @:{'words.generated.til'} @:{'words.generated.denne'} @:{'words.generated.afdeling'}.",
|
||||
"reader_unavailable": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.terminal'} @:{'words.generated.er'} @:{'words.generated.tilgængelig'} @:{'words.generated.lige'} @:{'words.generated.nu'}. @:{'words.generated.opdater'} listen @:{'words.generated.eller'} @:{'words.generated.vent'} @:{'words.generated.pa'}, @:{'words.generated.at'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.terminal'} kommer @:{'words.generated.online'}.",
|
||||
"setup_required_title": "@:{'words.generated.kortbetaling'} @:{'words.generated.er'} @:{'words.generated.ikke'} @:{'words.generated.klar'} @:{'words.generated.til'} @:{'words.generated.denne'} @:{'words.generated.afdeling'}.",
|
||||
"waiting_for_reader": "@.capitalize:{'words.generated.betalingen'} @:{'words.generated.er'} @:{'words.generated.i'} @:{'words.generated.gang'} @:{'words.generated.pa'} @:{'words.replication.article.host_mention'} @:{'words.generated.valgte'} @:{'words.generated.terminal'}. @:{'words.generated.opdater'} @:{'words.generated.status'}, @:{'words.generated.hvis'} terminalen @:{'words.generated.allerede'} @:{'words.generated.har'} @:{'words.generated.læst'} kortet."
|
||||
|
||||
@@ -1972,7 +1972,7 @@
|
||||
"orders": {
|
||||
"subtitle": "@:{'templates.generated.compat.objects.orders.description'}"
|
||||
},
|
||||
"pay_with_stripe": "@.capitalize:{'words.generated.mit'} @:{'words.generated.stripe'} @:{'words.generated.bezahlen'}",
|
||||
"pay_with_stripe": "Kortbetaling",
|
||||
"po_number": "@:{'words.generated.po'}-@.capitalize:{'words.generated.nummer'}",
|
||||
"price_changed_from": "@:{'words.generated.preis'} @:{'words.generated.ge'}?@:{'words.generated.ndert'} @:{'words.generated.von'} {from} @:{'words.generated.dkk'} @:{'words.generated.auf'} {to} @:{'words.generated.dkk'} / @:{'words.generated.einheit'} ({total} @:{'words.generated.dkk'} @:{'words.generated.gesamt'})",
|
||||
"recent_scan_details_empty": "@.capitalize:{'words.generated.fur'} @:{'words.generated.diesen'} @:{'words.generated.scan'} @:{'words.generated.sind'} @:{'words.generated.keine'} @:{'words.generated.fahrzeugdetails'} @:{'words.generated.verfugbar'}.",
|
||||
|
||||
@@ -1693,7 +1693,7 @@
|
||||
"orders": {
|
||||
"subtitle": "@:{'templates.generated.compat.objects.orders.description'}"
|
||||
},
|
||||
"pay_with_stripe": "@:{'words.generated.pay'} @:{'words.generated.with'} @:{'words.generated.stripe'}",
|
||||
"pay_with_stripe": "Kortbetaling",
|
||||
"po_number": "@:{'words.generated.po'} @.capitalize:{'words.generated.number'}",
|
||||
"price_changed_from": "@.capitalize:{'words.generated.price'} @:{'words.generated.changed'} @:{'words.generated.from'} {from} @:{'words.generated.dkk'} @:{'words.generated.to'} {to} @:{'words.generated.dkk'} / @:{'words.generated.unit'} ({total} @:{'words.generated.dkk'} @:{'words.generated.total'})",
|
||||
"recent_scan_details_empty": "@.capitalize:{'words.generated.no'} @:{'words.generated.vehicle'} @:{'words.generated.details'} @:{'words.generated.are'} @:{'words.generated.available'} @:{'words.generated.for'} @:{'words.generated.this'} @:{'words.generated.scan'}.",
|
||||
|
||||
@@ -1975,7 +1975,7 @@
|
||||
"orders": {
|
||||
"subtitle": "@:{'templates.generated.compat.objects.orders.description'}"
|
||||
},
|
||||
"pay_with_stripe": "Betal @:{'words.generated.med'} @:{'words.generated.stripe'}",
|
||||
"pay_with_stripe": "Kortbetaling",
|
||||
"po_number": "@:{'words.generated.po'}-@:{'words.generated.nummer'}",
|
||||
"price_changed_from": "@.capitalize:{'words.generated.pris'} @:{'words.generated.endret'} @:{'words.generated.fra'} {from} @:{'words.generated.dkk'} @:{'words.generated.til'} {to} @:{'words.generated.dkk'} / @:{'words.generated.enhet'} ({total} @:{'words.generated.dkk'} @:{'words.generated.totalt'})",
|
||||
"recent_scan_details_empty": "@.capitalize:{'words.generated.det'} @:{'words.generated.finnes'} @:{'words.generated.ingen'} @:{'words.generated.kjøretøydetaljer'} @:{'words.generated.for'} @:{'words.generated.denne'} skanningen.",
|
||||
|
||||
@@ -2025,7 +2025,7 @@
|
||||
"orders": {
|
||||
"subtitle": "@:{'templates.generated.compat.objects.orders.description'}"
|
||||
},
|
||||
"pay_with_stripe": "@:{'words.generated.betala'} @:{'words.generated.med'} @:{'words.generated.stripe'}",
|
||||
"pay_with_stripe": "Kortbetaling",
|
||||
"po_number": "@:{'words.generated.po'}-@:{'words.generated.nummer'}",
|
||||
"price_changed_from": "@.capitalize:{'words.generated.price'} @:{'words.generated.changed'} @:{'words.generated.from'} {from} @:{'words.generated.dkk'} @:{'words.generated.to'} {to} @:{'words.generated.dkk'} / @:{'words.generated.unit'} ({total} @:{'words.generated.dkk'} @:{'words.generated.total'})",
|
||||
"recent_scan_details_empty": "@.capitalize:{'words.generated.det'} @:{'words.generated.finns'} @:{'words.generated.inga'} @:{'words.generated.fordonsdetaljer'} @:{'words.generated.for'} @:{'words.replication.article.host_mention'} @:{'words.generated.har_2'} skanningen.",
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
"orders": {
|
||||
"subtitle": "@.capitalize:{'terms.glossary.administrer'} @:{'terms.glossary.ordrer'}"
|
||||
},
|
||||
"pay_with_stripe": "@:{'terms.glossary.betal'} @:{'terms.glossary.med'} @:{'terms.glossary.stripe'}",
|
||||
"pay_with_stripe": "Kortbetaling",
|
||||
"po_number": "@:{'terms.glossary.po'} @.capitalize:{'terms.glossary.nummer'}",
|
||||
"price_changed_from": "@:{'terms.glossary.prisen'} @:{'terms.glossary.er'} @:{'terms.glossary.ændret'} @:{'terms.glossary.fra'} {from} @:{'terms.glossary.dkk'} @:{'terms.glossary.til'} {to} @:{'terms.glossary.dkk'} / @:{'terms.glossary.enhed'} ({total} @:{'terms.glossary.dkk'} @:{'terms.glossary.total'})",
|
||||
"recent_scan_details_empty": "@.capitalize:{'terms.glossary.der'} @:{'terms.glossary.er'} @:{'terms.glossary.ingen'} @:{'terms.glossary.køretøjsdetaljer'} @:{'terms.glossary.til'} @:{'terms.glossary.denne'} @:{'terms.glossary.skanning'}.",
|
||||
|
||||
@@ -80,15 +80,15 @@
|
||||
"create_payment_intent": "@.capitalize:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.oprette'} @:{'terms.glossary.payment'} @.capitalize:{'terms.glossary.intent'}.",
|
||||
"delete_payment_intent": "@.capitalize:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.slette'} @:{'terms.glossary.payment'} @.capitalize:{'terms.glossary.intent'}.",
|
||||
"load_payment_intent_state": "@.capitalize:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.hente'} @:{'terms.glossary.betalingsstatus'}.",
|
||||
"load_readers": "@.capitalize:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.hente'} @:{'terms.glossary.stripe'}-@:{'terms.glossary.terminaler'}.",
|
||||
"load_readers": "@.capitalize:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.hente'} kortbetalingsterminaler.",
|
||||
"select_reader_before_payment": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.terminal'}, @:{'terms.glossary.før'} @:{'terms.glossary.betalingen'} startes."
|
||||
},
|
||||
"headings": {
|
||||
"card_payment": "@:{'terms.glossary.kortbetaling'}",
|
||||
"terminal": "@:{'terms.glossary.stripe'} @.capitalize:{'terms.glossary.terminal'}"
|
||||
"terminal": "Kortbetalingsterminal"
|
||||
},
|
||||
"messages": {
|
||||
"contact_superuser": "@.capitalize:{'terms.glossary.kontakt'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.superbruger'} @:{'terms.glossary.for'} @:{'terms.glossary.at'} åbne @:{'terms.glossary.stripe'}-opsætningen @:{'terms.glossary.og'} @:{'terms.glossary.vælge'} @:{'terms.replication.host_definite_suffix'} terminalplacering @:{'terms.glossary.til'} @:{'terms.glossary.denne'} @:{'terms.glossary.afdeling'}.",
|
||||
"contact_superuser": "@.capitalize:{'terms.glossary.kontakt'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.superbruger'} @:{'terms.glossary.for'} @:{'terms.glossary.at'} åbne kortbetalingsopsætningen @:{'terms.glossary.og'} @:{'terms.glossary.vælge'} @:{'terms.replication.host_definite_suffix'} terminalplacering @:{'terms.glossary.til'} @:{'terms.glossary.denne'} @:{'terms.glossary.afdeling'}.",
|
||||
"reader_unavailable": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.terminal'} @:{'terms.glossary.er'} @:{'terms.glossary.tilgængelig'} @:{'terms.glossary.lige'} @:{'terms.glossary.nu'}. @:{'terms.glossary.opdater'} listen @:{'terms.glossary.eller'} @:{'terms.glossary.vent'} @:{'terms.glossary.pa'}, @:{'terms.glossary.at'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.terminal'} kommer @:{'terms.glossary.online'}.",
|
||||
"setup_required_title": "@:{'terms.glossary.kortbetaling'} @:{'terms.glossary.er'} @:{'terms.glossary.ikke'} @:{'terms.glossary.klar'} @:{'terms.glossary.til'} @:{'terms.glossary.denne'} @:{'terms.glossary.afdeling'}.",
|
||||
"waiting_for_reader": "@.capitalize:{'terms.glossary.betalingen'} @:{'terms.glossary.er'} @:{'terms.glossary.i'} @:{'terms.glossary.gang'} @:{'terms.glossary.pa'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valgte'} @:{'terms.glossary.terminal'}. @:{'terms.glossary.opdater'} @:{'terms.glossary.status'}, @:{'terms.glossary.hvis'} terminalen @:{'terms.glossary.allerede'} @:{'terms.glossary.har'} @:{'terms.glossary.læst'} kortet."
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
"orders": {
|
||||
"subtitle": "@:{'phrases.compat.objects.orders.description'}"
|
||||
},
|
||||
"pay_with_stripe": "@.capitalize:{'terms.glossary.mit'} @:{'terms.glossary.stripe'} @:{'terms.glossary.bezahlen'}",
|
||||
"pay_with_stripe": "Kortbetaling",
|
||||
"po_number": "@:{'terms.glossary.po'}-@.capitalize:{'terms.glossary.nummer'}",
|
||||
"price_changed_from": "@:{'terms.glossary.preis'} @:{'terms.glossary.ge'}?@:{'terms.glossary.ndert'} @:{'terms.glossary.von'} {from} @:{'terms.glossary.dkk'} @:{'terms.glossary.auf'} {to} @:{'terms.glossary.dkk'} / @:{'terms.glossary.einheit'} ({total} @:{'terms.glossary.dkk'} @:{'terms.glossary.gesamt'})",
|
||||
"recent_scan_details_empty": "@.capitalize:{'terms.glossary.fur'} @:{'terms.glossary.diesen'} @:{'terms.glossary.scan'} @:{'terms.glossary.sind'} @:{'terms.glossary.keine'} @:{'terms.glossary.fahrzeugdetails'} @:{'terms.glossary.verfugbar'}.",
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
"orders": {
|
||||
"subtitle": "@:{'phrases.compat.objects.orders.description'}"
|
||||
},
|
||||
"pay_with_stripe": "@:{'terms.glossary.pay'} @:{'terms.glossary.with'} @:{'terms.glossary.stripe'}",
|
||||
"pay_with_stripe": "Kortbetaling",
|
||||
"po_number": "@:{'terms.glossary.po'} @.capitalize:{'terms.glossary.number'}",
|
||||
"price_changed_from": "@.capitalize:{'terms.glossary.price'} @:{'terms.glossary.changed'} @:{'terms.glossary.from'} {from} @:{'terms.glossary.dkk'} @:{'terms.glossary.to'} {to} @:{'terms.glossary.dkk'} / @:{'terms.glossary.unit'} ({total} @:{'terms.glossary.dkk'} @:{'terms.glossary.total'})",
|
||||
"recent_scan_details_empty": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.details'} @:{'terms.glossary.are'} @:{'terms.glossary.available'} @:{'terms.glossary.for'} @:{'terms.glossary.this'} @:{'terms.glossary.scan'}.",
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
"orders": {
|
||||
"subtitle": "@:{'phrases.compat.objects.orders.description'}"
|
||||
},
|
||||
"pay_with_stripe": "Betal @:{'terms.glossary.med'} @:{'terms.glossary.stripe'}",
|
||||
"pay_with_stripe": "Kortbetaling",
|
||||
"po_number": "@:{'terms.glossary.po'}-@:{'terms.glossary.nummer'}",
|
||||
"price_changed_from": "@.capitalize:{'terms.glossary.pris'} @:{'terms.glossary.endret'} @:{'terms.glossary.fra'} {from} @:{'terms.glossary.dkk'} @:{'terms.glossary.til'} {to} @:{'terms.glossary.dkk'} / @:{'terms.glossary.enhet'} ({total} @:{'terms.glossary.dkk'} @:{'terms.glossary.totalt'})",
|
||||
"recent_scan_details_empty": "@.capitalize:{'terms.glossary.det'} @:{'terms.glossary.finnes'} @:{'terms.glossary.ingen'} @:{'terms.glossary.kjøretøydetaljer'} @:{'terms.glossary.for'} @:{'terms.glossary.denne'} skanningen.",
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
"orders": {
|
||||
"subtitle": "@:{'phrases.compat.objects.orders.description'}"
|
||||
},
|
||||
"pay_with_stripe": "@:{'terms.glossary.betala'} @:{'terms.glossary.med'} @:{'terms.glossary.stripe'}",
|
||||
"pay_with_stripe": "Kortbetaling",
|
||||
"po_number": "@:{'terms.glossary.po'}-@:{'terms.glossary.nummer'}",
|
||||
"price_changed_from": "@.capitalize:{'terms.glossary.price'} @:{'terms.glossary.changed'} @:{'terms.glossary.from'} {from} @:{'terms.glossary.dkk'} @:{'terms.glossary.to'} {to} @:{'terms.glossary.dkk'} / @:{'terms.glossary.unit'} ({total} @:{'terms.glossary.dkk'} @:{'terms.glossary.total'})",
|
||||
"recent_scan_details_empty": "@.capitalize:{'terms.glossary.det'} @:{'terms.glossary.finns'} @:{'terms.glossary.inga'} @:{'terms.glossary.fordonsdetaljer'} @:{'terms.glossary.for'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.har_2'} skanningen.",
|
||||
|
||||
@@ -24,7 +24,6 @@ import "@/components/displays/department/pos/steps/PosDepartmentStep2.vue";
|
||||
import SelectProductsFormPOS from "@/components/forms/department/pos/SelectProductsFormPOS.vue";
|
||||
import PosDesktopOrderWorkspace from "@/components/displays/department/pos/order/PosDesktopOrderWorkspace.vue";
|
||||
import GetOrderInvoicePDFButton from "@/components/search/economic/getOrderInvoicePDFButton.vue";
|
||||
import ExportOrderToInvoiceStripeButton from "@/components/search/economic/exportOrderToInvoiceStripeButton.vue";
|
||||
import DepartmentDashboardPageWrapper from "@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue";
|
||||
import "@/components/displays/boxes/WhiteBox.vue";
|
||||
import ButtonsBox from "@/components/displays/boxes/ButtonsBox.vue";
|
||||
@@ -1499,7 +1498,6 @@ const isDisplayingReceipt = () => {
|
||||
<span class="icon"><i class="fas fa-print"></i></span>
|
||||
<span>{{ SessionUser.objects.global.language.payment.receipt }}</span>
|
||||
</button>
|
||||
<ExportOrderToInvoiceStripeButton :order_id="orderId" class="is-fullwidth" v-if="!isInvoiced() && invoiceUsingStripe() && isPaymentMethodStripe()" />
|
||||
<button class="button is-dark is-fullwidth" @click="openStripeInvoice" v-if="shouldShowStripeInvoiceAction()">
|
||||
<span class="icon"><i class="fas fa-file-invoice"></i></span>
|
||||
<span>{{ $t('pos.order.get_invoice') }}</span>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed, onBeforeUnmount } from "vue";
|
||||
import { Colors } from "@/ThemeConfig.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { StripeModule } from "@/components/stripe/StripeModule.vue";
|
||||
import { useEconomicQueueJob } from "@/composables/useEconomicQueueJob.js";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -215,17 +214,6 @@ const stripePayment = computed(() => {
|
||||
SessionUser.objects.global.language.show + " " + SessionUser.objects.global.language.in + " Stripe"
|
||||
}}</span>
|
||||
</a>
|
||||
<a
|
||||
v-if="stripePayment.amount_capturable > 0"
|
||||
class="card-footer-item"
|
||||
@click="StripeModule.paymentIntents.capturePaymentIntent(collectedOrderInvoice.stripe)"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<span>{{ SessionUser.objects.global.language.capture }}</span>
|
||||
</a>
|
||||
<a
|
||||
v-if="stripePayment.receipt_url"
|
||||
class="card-footer-item"
|
||||
|
||||
@@ -1822,6 +1822,7 @@ test("hides the draft export action in the order rail for non-superusers", async
|
||||
|
||||
await expect(draftExportFlow).toHaveCount(0);
|
||||
await expect(invoiceExportFlow).toHaveCount(0);
|
||||
await expect(page.locator('[data-action-key="economic-invoice-stripe-export-submit"]')).toHaveCount(0);
|
||||
await expect(receiptButton).toBeVisible();
|
||||
await expect(receiptButton).toContainText("Kvittering");
|
||||
|
||||
@@ -1858,6 +1859,7 @@ test("shows the draft export and receipt actions in the order rail for superuser
|
||||
await expect(draftExportFlow).toBeVisible();
|
||||
await expect(draftExportButton).toContainText("Send til e-conomic");
|
||||
await expect(invoiceExportFlow).toHaveCount(0);
|
||||
await expect(page.locator('[data-action-key="economic-invoice-stripe-export-submit"]')).toHaveCount(0);
|
||||
await expect(receiptButton).toBeVisible();
|
||||
await expect(receiptButton).toContainText("Kvittering");
|
||||
|
||||
|
||||
@@ -88,22 +88,6 @@ function createStoredPaymentIntent(orderId, overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createHostedInvoice(orderId, overrides = {}) {
|
||||
const amountDue = Number(overrides.amount_due ?? 1562);
|
||||
const paid = Boolean(overrides.paid ?? false);
|
||||
return {
|
||||
id: orderId,
|
||||
invoice_id: overrides.invoice_id || `in_${orderId}`,
|
||||
customer_id: Number(overrides.customer_id ?? CARD_CUSTOMER_ID),
|
||||
url: overrides.url || `https://stripe.example.test/invoices/${orderId}`,
|
||||
created_at: overrides.created_at || "2026-04-08 08:44:07",
|
||||
paid,
|
||||
status: overrides.status || (paid ? "paid" : "open"),
|
||||
amount_due: amountDue,
|
||||
amount_paid: Number(overrides.amount_paid ?? (paid ? amountDue : 0)),
|
||||
};
|
||||
}
|
||||
|
||||
async function primeSession(page, token = "desktop-card-token") {
|
||||
await primeMockSession(page, { token });
|
||||
}
|
||||
@@ -184,7 +168,7 @@ test.describe("POS desktop card payments", () => {
|
||||
await expect(stepThree.getByTestId("pos-stripe-open-setup")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("setup-required desktop flow still exposes the inline email card payment action", async ({ page }) => {
|
||||
test("setup-required desktop flow does not expose the email payment-link action", async ({ page }) => {
|
||||
const fixture = createCardOrderFixture({
|
||||
stripeReadersError: {
|
||||
status: 409,
|
||||
@@ -196,12 +180,8 @@ test.describe("POS desktop card payments", () => {
|
||||
const stepThree = await bootDesktopCardPayment(page, fixture);
|
||||
|
||||
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toBeVisible();
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-action")).toBeVisible();
|
||||
|
||||
await stepThree.getByTestId("pos-stripe-email-action").click();
|
||||
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-input")).toBeVisible();
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-submit")).toBeVisible();
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-action")).toHaveCount(0);
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-input")).toHaveCount(0);
|
||||
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -319,7 +299,7 @@ test.describe("POS desktop card payments", () => {
|
||||
|
||||
const stepThree = await bootDesktopCardPayment(page, fixture);
|
||||
|
||||
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toContainText("Betal med Stripe");
|
||||
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toContainText("Kortbetaling");
|
||||
const desktopLayout = stepThree.getByTestId("pos-stripe-desktop-layout");
|
||||
await expect(desktopLayout).toBeVisible();
|
||||
await expect(stepThree.getByTestId("pos-stripe-terminal-trigger-label")).toContainText("Aabenraa");
|
||||
@@ -391,7 +371,9 @@ test.describe("POS desktop card payments", () => {
|
||||
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("existing requires_capture intent on reload restores the capture action", async ({ page }) => {
|
||||
test("existing requires_capture intent on reload auto-captures without showing the capture action", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = createCardOrderFixture({
|
||||
paymentIntentsByOrderId: {
|
||||
[ORDER_ID]: createStoredPaymentIntent(ORDER_ID, {
|
||||
@@ -402,7 +384,8 @@ test.describe("POS desktop card payments", () => {
|
||||
|
||||
const stepThree = await bootDesktopCardPayment(page, fixture);
|
||||
|
||||
await expect(stepThree.getByTestId("pos-stripe-capture-intent")).toBeVisible();
|
||||
await expect(stepThree.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
|
||||
await expect(stepThree.getByTestId("pos-stripe-payment-succeeded")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -422,7 +405,7 @@ test.describe("POS desktop card payments", () => {
|
||||
expect(fixture.ordersById[ORDER_ID].completed_at).toBeNull();
|
||||
});
|
||||
|
||||
test("happy path create and capture exposes completion without auto-completing the order", async ({ page }) => {
|
||||
test("happy path create auto-captures and exposes completion without auto-completing the order", async ({ page }) => {
|
||||
const fixture = createCardOrderFixture();
|
||||
|
||||
const stepThree = await bootDesktopCardPayment(page, fixture);
|
||||
@@ -430,110 +413,21 @@ test.describe("POS desktop card payments", () => {
|
||||
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toBeVisible();
|
||||
await stepThree.getByTestId("pos-stripe-create-intent").click();
|
||||
|
||||
await expect(stepThree.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(stepThree.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
|
||||
expect(fixture.ordersById[ORDER_ID].completed_at).toBeNull();
|
||||
|
||||
await stepThree.getByTestId("pos-stripe-capture-intent").click();
|
||||
|
||||
await expect(stepThree.getByTestId("pos-stripe-payment-succeeded")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(stepThree.getByTestId("pos-next-step")).toBeVisible({ timeout: 10_000 });
|
||||
await page.waitForTimeout(750);
|
||||
expect(fixture.ordersById[ORDER_ID].completed_at).toBeNull();
|
||||
});
|
||||
|
||||
test("email card payment shows inline status and updates after polling", async ({ page }) => {
|
||||
const fixture = createCardOrderFixture();
|
||||
const stepThree = await bootDesktopCardPayment(page, fixture);
|
||||
const paidOrderPoll = new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
page.off("response", handleResponse);
|
||||
reject(new Error("Timed out waiting for the paid Stripe order poll response."));
|
||||
}, 10_000);
|
||||
|
||||
const handleResponse = async (response) => {
|
||||
if (response.request().method() !== "GET") {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(response.url());
|
||||
if (!url.pathname.endsWith("/order")) {
|
||||
return;
|
||||
}
|
||||
if (url.searchParams.get("id") !== String(ORDER_ID) || url.searchParams.get("include_all") !== "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
const stripeModuleOrders =
|
||||
payload?.data?.includes?.stripeModuleOrders ||
|
||||
payload?.includes?.stripeModuleOrders ||
|
||||
payload?.data?.stripeModuleOrders ||
|
||||
{};
|
||||
|
||||
if (String(stripeModuleOrders?.status || "") !== "paid") {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
page.off("response", handleResponse);
|
||||
resolve(stripeModuleOrders);
|
||||
};
|
||||
|
||||
page.on("response", handleResponse);
|
||||
});
|
||||
|
||||
await stepThree.getByTestId("pos-stripe-email-action").click();
|
||||
await stepThree.getByTestId("pos-stripe-email-input").fill("card-email@example.com");
|
||||
await stepThree.getByTestId("pos-stripe-email-submit").click();
|
||||
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-status")).toHaveText(/Afventer betaling|Awaiting payment/);
|
||||
|
||||
fixture.stripeModuleOrdersByOrderId[ORDER_ID] = createHostedInvoice(ORDER_ID, {
|
||||
...fixture.stripeModuleOrdersByOrderId[ORDER_ID],
|
||||
status: "paid",
|
||||
paid: true,
|
||||
amount_due: 0,
|
||||
amount_paid: 1562,
|
||||
});
|
||||
|
||||
await paidOrderPoll;
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-status")).toHaveText(/Betalt|Paid/, { timeout: 1_000 });
|
||||
await expect(stepThree.getByTestId("pos-next-step")).toBeVisible({ timeout: 1_000 });
|
||||
});
|
||||
|
||||
test("email card payment can be cancelled before submit and returns to the idle action", async ({ page }) => {
|
||||
test("email payment-link controls are hidden in desktop card payment", async ({ page }) => {
|
||||
const fixture = createCardOrderFixture();
|
||||
const stepThree = await bootDesktopCardPayment(page, fixture);
|
||||
|
||||
await stepThree.getByTestId("pos-stripe-email-action").click();
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-input")).toBeVisible();
|
||||
|
||||
await stepThree.getByTestId("pos-stripe-email-cancel").click();
|
||||
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-action")).toBeVisible();
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-action")).toHaveCount(0);
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-input")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("email card payment cancel clears the hosted invoice selection and restores the idle action", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = createCardOrderFixture();
|
||||
const stepThree = await bootDesktopCardPayment(page, fixture);
|
||||
|
||||
await stepThree.getByTestId("pos-stripe-email-action").click();
|
||||
await stepThree.getByTestId("pos-stripe-email-input").fill("cancel-email@example.com");
|
||||
await stepThree.getByTestId("pos-stripe-email-submit").click();
|
||||
|
||||
await expect
|
||||
.poll(() => fixture.stripeModuleOrdersByOrderId[ORDER_ID]?.invoice_id || null, { timeout: 10_000 })
|
||||
.not.toBeNull();
|
||||
|
||||
await stepThree.getByTestId("pos-stripe-email-cancel").click();
|
||||
|
||||
await expect
|
||||
.poll(() => Object.keys(fixture.stripeModuleOrdersByOrderId[ORDER_ID] || {}).length, { timeout: 10_000 })
|
||||
.toBe(0);
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-action")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(stepThree.getByTestId("pos-stripe-email-status")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ test.describe("POS mobile card payments", () => {
|
||||
.toBe(CARD_CUSTOMER_ID);
|
||||
});
|
||||
|
||||
test("@smoke direct card flow bridges step 1 to capture and resets to scanner", async ({ page }) => {
|
||||
test("@smoke direct card flow bridges step 1 through automatic capture and resets to scanner", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture();
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-card-bridge-token",
|
||||
@@ -143,8 +143,7 @@ test.describe("POS mobile card payments", () => {
|
||||
await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-stripe-create-intent").click();
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-stripe-capture-intent").click();
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
|
||||
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
|
||||
@@ -270,7 +269,7 @@ test.describe("POS mobile card payments", () => {
|
||||
expect(fixture.requestCounters.paymentIntentCreate).toBe(0);
|
||||
});
|
||||
|
||||
test("reader available creates intent once and reaches capture state", async ({ page }) => {
|
||||
test("reader available creates intent once and automatically captures", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture();
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-card-create-token",
|
||||
@@ -290,11 +289,11 @@ test.describe("POS mobile card payments", () => {
|
||||
await page.getByTestId("pos-stripe-create-intent").click();
|
||||
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentCreate, { timeout: 10_000 }).toBe(1);
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
|
||||
expect(fixture.requestCounters.markAsCompleted).toBe(0);
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(1);
|
||||
});
|
||||
|
||||
test("email card payment action expands inline and shows hosted invoice status", async ({ page }) => {
|
||||
test("email card payment action is not shown", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture();
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-card-email-token",
|
||||
@@ -309,19 +308,11 @@ test.describe("POS mobile card payments", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-stripe-email-action")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-stripe-email-action").click();
|
||||
await expect(page.getByTestId("pos-stripe-email-input")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-stripe-email-cancel")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("pos-stripe-email-input").fill("mobile-card@example.com");
|
||||
await page.getByTestId("pos-stripe-email-submit").click();
|
||||
|
||||
await expect.poll(() => fixture.requestCounters.stripeInvoiceSend, { timeout: 10_000 }).toBe(1);
|
||||
await expect(page.getByTestId("pos-stripe-email-status")).toHaveText(/Afventer betaling|Awaiting payment/);
|
||||
await expect(page.getByTestId("pos-stripe-email-action")).toHaveCount(0);
|
||||
expect(fixture.requestCounters.stripeInvoiceSend).toBe(0);
|
||||
});
|
||||
|
||||
test("step 3 back offers resume or cancel when payment is active", async ({ page }) => {
|
||||
test("step 3 back is disabled once card authorization auto-captures", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture();
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-card-cancel-token",
|
||||
@@ -338,24 +329,14 @@ test.describe("POS mobile card payments", () => {
|
||||
|
||||
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-stripe-create-intent").click();
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(1);
|
||||
|
||||
await page.getByTestId("pos-mobile-step-3-back").click();
|
||||
await expect(page.locator(".swal2-popup")).toBeVisible({ timeout: 10_000 });
|
||||
await page.locator(".swal2-confirm").click();
|
||||
await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator(".swal2-popup")).toHaveCount(0);
|
||||
await expect(page.getByTestId("pos-mobile-step-3-back")).toBeDisabled();
|
||||
expect(fixture.requestCounters.paymentIntentDelete).toBe(0);
|
||||
|
||||
await page.getByTestId("pos-mobile-step-3-back").click();
|
||||
await expect(page.locator(".swal2-popup")).toBeVisible({ timeout: 10_000 });
|
||||
await page.locator(".swal2-deny").click();
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentDelete, { timeout: 10_000 }).toBe(1);
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page).toHaveURL(/step=2/);
|
||||
expect(fixture.requestCounters.markAsCompleted).toBe(0);
|
||||
});
|
||||
|
||||
test("capture success completes once and resets scanner flow", async ({ page }) => {
|
||||
test("automatic capture success completes once and resets scanner flow", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture();
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-card-capture-success-token",
|
||||
@@ -372,9 +353,7 @@ test.describe("POS mobile card payments", () => {
|
||||
|
||||
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-stripe-create-intent").click();
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("pos-stripe-capture-intent").click();
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
|
||||
await expect
|
||||
@@ -406,8 +385,8 @@ test.describe("POS mobile card payments", () => {
|
||||
|
||||
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-stripe-create-intent").click();
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
|
||||
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 2_500 }).toBe(0);
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(1);
|
||||
});
|
||||
|
||||
test("step 3 bootstrap recovery fetches existing requires_capture intent", async ({ page }) => {
|
||||
@@ -433,7 +412,8 @@ test.describe("POS mobile card payments", () => {
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 });
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentGet, { timeout: 10_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(1);
|
||||
expect(fixture.requestCounters.paymentIntentCreate).toBe(0);
|
||||
});
|
||||
|
||||
@@ -538,15 +518,11 @@ test.describe("POS mobile card payments", () => {
|
||||
|
||||
await page.getByTestId("pos-stripe-create-intent").click();
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentCreate, { timeout: 10_000 }).toBe(3);
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("pos-stripe-capture-intent").click();
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(2);
|
||||
await expect(page.getByTestId("pos-stripe-error")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-stripe-error").click();
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("pos-stripe-capture-intent").click();
|
||||
await expect(page.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
|
||||
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(3);
|
||||
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
|
||||
});
|
||||
|
||||
@@ -37,4 +37,9 @@ describe("collected invoice stripe queue wiring", () => {
|
||||
expect(stripeManageSource).toContain(':disabled="!canBookStripeInvoice"');
|
||||
expect(stripeManageSource).toContain("isStripeTransferCompleted.value");
|
||||
});
|
||||
|
||||
it("does not expose manual payment capture", () => {
|
||||
expect(stripeManageSource).not.toContain("capturePaymentIntent(");
|
||||
expect(stripeManageSource).not.toContain("stripePayment.amount_capturable > 0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,6 +179,27 @@ describe("CustomerSearchFieldPos", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("selects the card-payment customer without exposing Stripe wording", async () => {
|
||||
const onCustomerSelected = vi.fn();
|
||||
const wrapper = mountComponent({
|
||||
showCardPaymentButton: true,
|
||||
onCustomerSelected,
|
||||
});
|
||||
|
||||
await wrapper.get('[data-testid="pos-card-payment-inline-action"]').trigger("click");
|
||||
await nextTick();
|
||||
|
||||
expect(posState.selectCustomer).toHaveBeenCalledWith({
|
||||
customerNumber: 999,
|
||||
name: "Kortbetaling",
|
||||
});
|
||||
expect(onCustomerSelected).toHaveBeenCalledWith({
|
||||
customerNumber: 999,
|
||||
name: "Kortbetaling",
|
||||
});
|
||||
expect(wrapper.text()).not.toContain("Stripe");
|
||||
});
|
||||
|
||||
it("shows the inline Ny kunde form and hides manual search while the mode is active", async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
|
||||