• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kiva / ui / 27713730360

17 Jun 2026 07:18PM UTC coverage: 84.537% (-0.06%) from 84.601%
27713730360

push

github

web-flow
feat: [MP-2900-2905] - allow removal promotion rows in TY page express checkout modal and prevent close it

* feat: [MP-2900] - allow removal promotion rows and some code improvements

* feat: [MP-2905] - prevent closing modal when payment is in-progress

* feat: [MP-2900] - added 'shop.validatePreCheckout' mutation on submit order

5309 of 5848 branches covered (90.78%)

Branch coverage included in aggregate %.

151 of 164 new or added lines in 6 files covered. (92.07%)

36 existing lines in 1 file now uncovered.

41288 of 49272 relevant lines covered (83.8%)

36.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.53
/src/components/Thanks/ExpressCheckout/ExpressCheckoutModal.vue
1
<template>
2
        <KvLightbox
1✔
3
                title="Confirm Payment"
1✔
4
                :visible="lightboxOpen"
1✔
5
                :prevent-close="paying"
1✔
6
                @lightbox-closed="closeLightbox"
1✔
7
        >
8
                <form
1✔
9
                        v-if="lightboxOpen"
1✔
10
                        style="max-width: 25rem;"
1✔
11
                        class="tw-mx-auto"
1✔
12
                        @submit.prevent="onSubmit"
1✔
13
                >
14
                        <ExpressCheckoutTotals
1✔
15
                                :loan="loan"
1✔
16
                        />
1✔
17

18
                        <KvPaymentSelect
1✔
19
                                v-if="depositRequired"
1✔
20
                                :amount="totalDue"
1✔
21
                                :auth-token="dropInAuthToken"
1✔
22
                                :drop-in-name="dropInName"
1✔
23
                                flow="checkout"
1✔
24
                                :google-pay-merchant-id="googlePayMerchantId"
1✔
25
                                :preselect-vaulted-payment-method="true"
1✔
26
                                @transactions-enabled="transactionsEnabled = $event"
1✔
27
                                @error="$showTipMsg($event, 'error')"
1✔
28
                        />
1✔
29

30
                        <p
1✔
31
                                class="tw-text-caption tw-mt-1 tw-mb-7 md:!tw-mb-2"
2✔
32
                                style="font-weight: 611;"
2✔
33
                        >
2✔
34
                                By clicking &ldquo;Confirm payment&rdquo; you will be funding this loan
35
                                and we&rsquo;ll charge your payment method for the selected amount.
36
                        </p>
2✔
37

38
                        <KvButton
1✔
39
                                :state="paymentButtonState"
1✔
40
                                type="submit"
1✔
41
                                class="tw-w-full tw-my-1"
1✔
42
                        >
1✔
43
                                Complete order
44
                        </KvButton>
1✔
45

46
                        <p
1!
UNCOV
47
                                v-if="depositRequired"
×
UNCOV
48
                                class="tw-text-small tw-text-secondary tw-text-center"
×
UNCOV
49
                        >
×
50
                                Thanks to PayPal, Kiva receives free payment processing for all transactions.
UNCOV
51
                        </p>
×
52
                </form>
1✔
53
        </KvLightbox>
1✔
54
</template>
55

56
<script setup>
57
import {
1✔
58
        computed,
59
        inject,
60
        onBeforeUnmount,
61
        ref,
62
} from 'vue';
63
import { useRouter } from 'vue-router';
1✔
64
import numeral from 'numeral';
1✔
65
import {
1✔
66
        basketTotalsQuery,
67
        createBasket,
68
        executeOneTimeCheckout,
69
        getBasketID,
70
        getClientToken,
71
        KvPaymentSelect,
72
        trackTransactionEvent,
73
        useBraintreeDropIn,
74
        watchBasketTotals,
75
} from '@kiva/kv-shop';
76
import { KvButton, KvLightbox } from '@kiva/kv-components';
1✔
77
import ExpressCheckoutTotals from '#src/components/Thanks/ExpressCheckout/ExpressCheckoutTotals';
1✔
78
import useTipMessage from '#src/composables/useTipMessage';
1✔
79
import {
1✔
80
        formatPreCheckoutValidationErrors,
81
        validatePreCheckoutBasket,
82
} from '#src/util/checkout/checkoutValidationUtils';
83
import logFormatter from '#src/util/logFormatter';
1✔
84

85
defineProps({
86
        loan: {
87
                type: Object,
88
                default: () => ({}),
89
        },
90
        analyticsCategory: {
91
                type: String,
92
                default: 'post-checkout',
93
        },
94
});
95

96
const emit = defineEmits(['close', 'checkout-complete']);
1✔
97

98
const apollo = inject('apollo');
1✔
99
const $appConfig = inject('$appConfig', {});
1✔
100
const { $showTipMsg } = useTipMessage(apollo);
1✔
101
const router = useRouter();
1✔
102

103
const googlePayMerchantId = $appConfig?.googlePay?.merchantId ?? '';
1!
104
const dropInName = 'express-checkout';
1✔
105

106
const lightboxOpen = ref(false);
1✔
107
const paying = ref(false);
1✔
108
const totalDue = ref('0.00');
1✔
109
const transactionsEnabled = ref(false);
1✔
110
const dropInAuthToken = ref('');
1✔
111
let totalsSubscription = null;
1✔
112

113
const depositRequired = computed(() => (numeral(totalDue.value).value() ?? 0) > 0);
1!
114

115
const paymentButtonState = computed(() => {
1✔
116
        if (depositRequired.value && !transactionsEnabled.value) {
4!
UNCOV
117
                return 'disabled';
×
UNCOV
118
        }
×
119
        if (paying.value) {
4✔
120
                return 'loading';
2✔
121
        }
2✔
122
        return '';
2✔
123
});
1✔
124

125
const subscribeTotals = () => {
1✔
126
        totalsSubscription?.unsubscribe();
2!
127
        totalsSubscription = watchBasketTotals(apollo).subscribe({
2✔
128
                next: ({ data }) => {
2✔
129
                        totalDue.value = data?.shop?.basket?.totals?.creditAmountNeeded || '0.00';
2!
130
                },
2✔
131
                error: e => {
2✔
UNCOV
132
                        logFormatter(`ExpressCheckoutModal: ${e}`, 'error');
×
133
                },
2✔
134
        });
2✔
135
};
2✔
136

137
const closeLightbox = () => {
1✔
138
        lightboxOpen.value = false;
1✔
139
        totalsSubscription?.unsubscribe();
1✔
140
        totalsSubscription = null;
1✔
141
        emit('close');
1✔
142
};
1✔
143

144
const openLightbox = async () => {
1✔
145
        try {
2✔
146
                // Prime the basket totals cache so the watch subscription (and the
2✔
147
                // totals component) bind to the freshly-mutated basket. The
2✔
148
                // updateLoanReservation mutation that runs before this modal opens
2✔
149
                // does not include totals in its response, so Apollo has no way to
2✔
150
                // invalidate the cached totals on its own.
2✔
151
                await apollo.query({
2✔
152
                        query: basketTotalsQuery,
2✔
153
                        variables: { basketId: getBasketID() },
2✔
154
                        fetchPolicy: 'network-only',
2✔
155
                });
2✔
156

157
                // Only renders this modal for logged-in users
2✔
158
                // (GoalEntrypoint is gated by `v-if="!isGuest"`), so we always fetch
2✔
159
                // the customer-scoped client token here.
2✔
160
                dropInAuthToken.value = await getClientToken(apollo) ?? '';
2!
161
        } catch (e) {
2!
UNCOV
162
                const message = e?.message || 'Something went wrong. Please, refresh the page and try again.';
×
UNCOV
163
                $showTipMsg(message, 'error');
×
UNCOV
164
                return false;
×
UNCOV
165
        }
×
166

167
        subscribeTotals();
2✔
168
        lightboxOpen.value = true;
2✔
169
        return true;
2✔
170
};
2✔
171

172
const onSubmit = async () => {
1✔
173
        if (paying.value) return;
2!
174
        paying.value = true;
2✔
175

176
        try {
2✔
177
                const validationStatus = await validatePreCheckoutBasket({ apollo });
2✔
178
                if (validationStatus !== true) {
2✔
179
                        const validationMessage = formatPreCheckoutValidationErrors(validationStatus);
1✔
180
                        logFormatter(`ExpressCheckoutModal validation failed: ${validationMessage}`, 'error');
1✔
181
                        paying.value = false;
1✔
182
                        closeLightbox();
1✔
183
                        router.push('/basket');
1✔
184
                        return;
1✔
185
                }
1✔
186

187
                const options = { apollo, deactivateRedirect: true };
1✔
188
                if (depositRequired.value) {
2!
UNCOV
189
                        options.braintree = useBraintreeDropIn(dropInName);
×
UNCOV
190
                }
✔
191

192
                const transactionResult = await executeOneTimeCheckout(options);
1✔
193

194
                // Validate the result BEFORE side effects. `executeOneTimeCheckout`
1✔
195
                // only throws when GraphQL errors are present — a declined card may
1✔
196
                // return cleanly with `status !== 'COMPLETED'`. Without this guard
1✔
197
                // we'd track a phantom transaction and wipe the basket via
1✔
198
                // `createBasket` before the user can retry.
1✔
199
                if (transactionResult?.data?.checkoutStatus?.status !== 'COMPLETED') {
2!
UNCOV
200
                        throw new Error('Checkout failed');
×
UNCOV
201
                }
✔
202

203
                const checkoutId = transactionResult?.data?.checkoutStatus?.receipt?.checkoutId;
2✔
204
                if (!checkoutId) {
2!
UNCOV
205
                        // Defensive: status is COMPLETED but the receipt id is missing.
×
UNCOV
206
                        // Throw so the user gets a tip message instead of silently
×
UNCOV
207
                        // paying without ever reaching the new Thanks page.
×
UNCOV
208
                        throw new Error('Checkout completed but receipt is missing');
×
UNCOV
209
                }
✔
210

211
                await trackTransactionEvent({
1✔
212
                        apollo,
1✔
213
                        transactionId: Number(checkoutId),
1✔
214
                });
1✔
215

216
                await createBasket(apollo);
1✔
217

218
                emit('checkout-complete', {
1✔
219
                        transactionId: checkoutId,
1✔
220
                        amount: numeral(totalDue.value).format('0.00'),
1✔
221
                        transactionInfo: transactionResult?.data?.checkoutStatus,
2✔
222
                });
2✔
223
        } catch (e) {
2!
UNCOV
224
                if (e?.code === 'shop.failedCheckoutValidation') {
×
UNCOV
225
                        closeLightbox();
×
UNCOV
226
                        router.push('/basket');
×
UNCOV
227
                        return;
×
UNCOV
228
                }
×
229

UNCOV
230
                let errorMsg = 'Something went wrong. Please, refresh the page and try again.';
×
231

UNCOV
232
                if (e?.code === 'shop.dropinNoPaymentMethod') {
×
UNCOV
233
                        // eslint-disable-next-line max-len
×
UNCOV
234
                        errorMsg = 'There was a problem validating your payment information. Please double-check the details and try again.';
×
UNCOV
235
                } else if (e?.message && e?.code !== 'shop.dropinRequired') {
×
UNCOV
236
                        errorMsg = e.message;
×
UNCOV
237
                }
×
UNCOV
238
                paying.value = false;
×
UNCOV
239
                $showTipMsg(errorMsg, 'error');
×
UNCOV
240
                closeLightbox();
×
UNCOV
241
        }
×
242
};
2✔
243

244
onBeforeUnmount(() => {
1✔
245
        totalsSubscription?.unsubscribe();
2✔
246
        totalsSubscription = null;
2✔
247
});
1✔
248

249
defineExpose({
1✔
250
        openLightbox,
1✔
251
});
1✔
252
</script>
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc