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:** ![POS card payment before
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-pos-card-payment-mobile.png)

**After:** ![POS card payment after
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-pos-card-payment-mobile.png)

#### Tablet

**Before:** ![POS card payment before
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-pos-card-payment-tablet.png)

**After:** ![POS card payment after
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-pos-card-payment-tablet.png)

#### Desktop

**Before:** ![POS card payment before
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-pos-card-payment-desktop.png)

**After:** ![POS card payment after
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-pos-card-payment-desktop.png)

### View: Authorized payment capture

**Description:** The manual capture action is replaced by an
automatically initiated capture and its in-progress state.

#### Mobile

**Before:** ![Payment capture before
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-payment-capture-mobile.png)

**After:** ![Payment capture after
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-payment-capture-mobile.png)

#### Tablet

**Before:** ![Payment capture before
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-payment-capture-tablet.png)

**After:** ![Payment capture after
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-payment-capture-tablet.png)

#### Desktop

**Before:** ![Payment capture before
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-payment-capture-desktop.png)

**After:** ![Payment capture after
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-payment-capture-desktop.png)

### View: Order-dashboard action rail

**Description:** The hosted Stripe invoice/payment-link action is
removed while normal order actions remain.

#### Mobile

**Before:** ![Order action rail before
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-order-dashboard-mobile.png)

**After:** ![Order action rail after
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-order-dashboard-mobile.png)

#### Tablet

**Before:** ![Order action rail before
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-order-dashboard-tablet.png)

**After:** ![Order action rail after
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-order-dashboard-tablet.png)

#### Desktop

**Before:** ![Order action rail before
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-order-dashboard-desktop.png)

**After:** ![Order action rail after
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-order-dashboard-desktop.png)

## 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.
This commit is contained in:
Jeppe B
2026-07-27 19:09:40 +02:00
committed by GitHub
parent b7859d4ede
commit 4f26ddd2cc
42 changed files with 152 additions and 859 deletions
+48
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

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>
+4 -4
View File
@@ -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."
+1 -1
View File
@@ -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'}.",
+1 -1
View File
@@ -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'}.",
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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"
+2
View File
@@ -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");
+13 -119
View File
@@ -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);
});
});
+19 -43
View File
@@ -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();