Skip to content

Commit 5d6335b

Browse files
VitaliiShpitalStorj Robot
authored andcommitted
web/satellite: optionally collect billing info during onboarding
Added custom optional form to onboarding upgrade flow. Issue: #7629 Change-Id: Ie0fa61ba79d9d1b346e7c91e0e88195cfc30857a
1 parent 55be126 commit 5d6335b

File tree

6 files changed

+305
-23
lines changed

6 files changed

+305
-23
lines changed

web/satellite/src/api/payments.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
AddFundsResponse,
2323
ProductCharges,
2424
ChargeCardIntent,
25-
PurchaseIntent,
25+
PurchaseRequest,
2626
} from '@/types/payments';
2727
import { HttpClient } from '@/utils/httpClient';
2828
import { Time } from '@/utils/time';
@@ -803,14 +803,13 @@ export class PaymentsHttpApi implements PaymentsApi {
803803
* Purchases makes a purchase using a credit card action.
804804
* Used for pricing packages and upgrade account.
805805
*
806-
* @param pmID - the Stripe payment method id or token of the credit card
807-
* @param intent - the intent of the purchase, either to purchase a package plan or upgrade account
806+
* @param request - purchase request
808807
* @param csrfProtectionToken - CSRF token
809808
* @throws Error
810809
*/
811-
public async purchase(pmID: string, intent: PurchaseIntent, csrfProtectionToken: string): Promise<void> {
810+
public async purchase(request: PurchaseRequest, csrfProtectionToken: string): Promise<void> {
812811
const path = `${this.ROOT_PATH}/purchase`;
813-
const response = await this.client.post(path, JSON.stringify({ token: pmID, intent }), { csrfProtectionToken });
812+
const response = await this.client.post(path, JSON.stringify(request), { csrfProtectionToken });
814813

815814
if (response.ok) {
816815
return;
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// Copyright (C) 2025 Storj Labs, Inc.
2+
// See LICENSE for copying information.
3+
4+
<template>
5+
<v-expansion-panels class="mt-4">
6+
<v-expansion-panel eager static @group:selected="({value}) => onPanelToggle(value)">
7+
<v-expansion-panel-title>Billing info (optional)</v-expansion-panel-title>
8+
<v-expansion-panel-text>
9+
<v-progress-linear rounded indeterminate :active="isLoading" />
10+
<v-row>
11+
<v-col>
12+
<div id="billing-address-element">
13+
<!-- A Stripe Address Element will be inserted here. -->
14+
</div>
15+
</v-col>
16+
<v-col>
17+
<template v-if="addressElementReady">
18+
<v-select
19+
v-model="selectedTax"
20+
label="Tax ID type"
21+
placeholder="Choose tax ID type"
22+
:disabled="!countryCode"
23+
:items="taxes"
24+
:item-title="(item: Tax) => item.name"
25+
:item-value="(item: Tax) => item"
26+
hide-details
27+
class="mb-3"
28+
/>
29+
30+
<v-text-field
31+
v-model="taxID"
32+
:disabled="!selectedTax"
33+
variant="outlined"
34+
label="Tax ID"
35+
placeholder="Enter your Tax ID"
36+
:hint="'e.g.: ' + selectedTax?.example"
37+
:hide-details="false"
38+
:maxlength="50"
39+
class="custom"
40+
/>
41+
</template>
42+
</v-col>
43+
</v-row>
44+
</v-expansion-panel-text>
45+
</v-expansion-panel>
46+
</v-expansion-panels>
47+
</template>
48+
49+
<script setup lang="ts">
50+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
51+
import { loadStripe } from '@stripe/stripe-js/pure';
52+
import {
53+
Stripe,
54+
StripeAddressElement,
55+
StripeElements,
56+
StripeElementsOptionsMode,
57+
StripeAddressElementChangeEvent,
58+
} from '@stripe/stripe-js';
59+
import { useTheme } from 'vuetify';
60+
import {
61+
VRow,
62+
VCol,
63+
VProgressLinear,
64+
VExpansionPanels,
65+
VExpansionPanel,
66+
VExpansionPanelTitle,
67+
VExpansionPanelText,
68+
VSelect,
69+
VTextField,
70+
} from 'vuetify/components';
71+
72+
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
73+
import { useNotify } from '@/composables/useNotify';
74+
import { useConfigStore } from '@/store/modules/configStore';
75+
import { useLoading } from '@/composables/useLoading';
76+
import { PurchaseBillingInfo, Tax } from '@/types/payments';
77+
import { useBillingStore } from '@/store/modules/billingStore';
78+
79+
const configStore = useConfigStore();
80+
const billingStore = useBillingStore();
81+
82+
const notify = useNotify();
83+
const theme = useTheme();
84+
const { isLoading, withLoading } = useLoading();
85+
86+
const addressElement = ref<StripeAddressElement>();
87+
const stripe = ref<Stripe | null>(null);
88+
const elements = ref<StripeElements | null>(null);
89+
const addressElementReady = ref(false);
90+
91+
const countryCode = ref<string>();
92+
const selectedTax = ref<Tax>();
93+
const taxID = ref<string>();
94+
95+
const taxes = computed<Tax[]>(() => billingStore.state.taxes);
96+
97+
function initStripe(): void {
98+
withLoading(async () => {
99+
const stripePublicKey = configStore.state.config.stripePublicKey;
100+
101+
try {
102+
stripe.value = await loadStripe(stripePublicKey);
103+
} catch (error) {
104+
notify.error(error.message, AnalyticsErrorEventSource.BILLING_STRIPE_INFO_FORM);
105+
return;
106+
}
107+
108+
if (!stripe.value) {
109+
notify.error('Unable to initialize stripe', AnalyticsErrorEventSource.BILLING_STRIPE_INFO_FORM);
110+
return;
111+
}
112+
113+
const options: StripeElementsOptionsMode = {
114+
appearance: {
115+
theme: theme.global.current.value.dark ? 'night' : 'stripe',
116+
labels: 'floating',
117+
},
118+
};
119+
elements.value = stripe.value.elements(options);
120+
if (!elements.value) {
121+
notify.error('Unable to instantiate elements', AnalyticsErrorEventSource.BILLING_STRIPE_INFO_FORM);
122+
}
123+
});
124+
}
125+
126+
function onPanelToggle(val: boolean): void {
127+
if (!val) {
128+
addressElement.value?.destroy();
129+
addressElementReady.value = false;
130+
taxID.value = undefined;
131+
return;
132+
}
133+
134+
if (!elements.value) {
135+
notify.error('Unable to instantiate elements', AnalyticsErrorEventSource.BILLING_STRIPE_INFO_FORM);
136+
return;
137+
}
138+
139+
addressElement.value = elements.value.create('address', { mode: 'billing' });
140+
addressElement.value.on('ready', () => {
141+
addressElementReady.value = true;
142+
});
143+
addressElement.value.on('change', (event: StripeAddressElementChangeEvent) => {
144+
if (countryCode.value !== event.value.address?.country) countryCode.value = event.value.address?.country;
145+
});
146+
addressElement.value.mount('#billing-address-element');
147+
}
148+
149+
/**
150+
* To be called by parent element to
151+
* validate and get filled form data.
152+
*/
153+
async function onSubmit(): Promise<PurchaseBillingInfo> {
154+
if (!(stripe.value && elements.value)) {
155+
throw new Error('Stripe is not initialized');
156+
}
157+
158+
if (!addressElement.value) {
159+
return {
160+
address: undefined,
161+
tax: undefined,
162+
};
163+
}
164+
165+
const { complete, value } = await addressElement.value.getValue();
166+
167+
return {
168+
address: complete ? {
169+
name: value.name,
170+
city: value.address.city,
171+
country: value.address.country,
172+
line1: value.address.line1,
173+
line2: value.address.line2,
174+
postalCode: value.address.postal_code,
175+
state: value.address.state,
176+
} : undefined,
177+
tax: selectedTax.value && taxID.value ? {
178+
type: selectedTax.value.code,
179+
value: taxID.value,
180+
} : undefined,
181+
};
182+
}
183+
184+
watch(countryCode, (code) => {
185+
withLoading(async () => {
186+
if (!code) {
187+
return;
188+
}
189+
selectedTax.value = undefined;
190+
try {
191+
await billingStore.getCountryTaxes(code ?? '');
192+
if (taxes.value.length === 1) {
193+
selectedTax.value = taxes.value[0];
194+
}
195+
} catch (e) {
196+
notify.notifyError(e);
197+
}
198+
});
199+
});
200+
201+
onMounted(() => {
202+
initStripe();
203+
});
204+
205+
onBeforeUnmount(() => {
206+
addressElement.value?.off('change');
207+
addressElement.value?.off('ready');
208+
});
209+
210+
defineExpose({
211+
onSubmit,
212+
});
213+
</script>
214+
215+
<style scoped lang="scss">
216+
:deep(.v-field__input) {
217+
min-height: 64px;
218+
}
219+
</style>

web/satellite/src/components/dialogs/upgradeAccountFlow/PricingPlanStep.vue

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
ref="stripeCardInput"
2626
@ready="stripeReady = true"
2727
/>
28+
<StripeBillingInfo
29+
v-if="collectBillingInfo"
30+
ref="stripeInfoForm"
31+
/>
2832
</div>
2933

3034
<template v-if="isAccountSetup">
@@ -139,13 +143,18 @@ import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/ana
139143
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
140144
import { ROUTES } from '@/router';
141145
import { useProjectsStore } from '@/store/modules/projectsStore';
146+
import { PurchaseBillingInfo, PurchaseIntent, PurchaseRequest } from '@/types/payments';
142147
143148
import StripeCardElement from '@/components/StripeCardElement.vue';
149+
import StripeBillingInfo from '@/components/StripeBillingInfo.vue';
144150
145151
interface StripeForm {
146152
onSubmit(): Promise<string>;
147153
initStripe(): Promise<string>;
148154
}
155+
interface StripeBillingInfoForm {
156+
onSubmit(): Promise<PurchaseBillingInfo>;
157+
}
149158
150159
const analyticsStore = useAnalyticsStore();
151160
const billingStore = useBillingStore();
@@ -159,6 +168,7 @@ const route = useRoute();
159168
const isSuccess = ref<boolean>(false);
160169
161170
const stripeCardInput = ref<StripeForm | null>(null);
171+
const stripeInfoForm = ref<StripeBillingInfoForm | null>(null);
162172
const stripeReady = ref<boolean>(false);
163173
164174
const props = withDefaults(defineProps<{
@@ -184,6 +194,8 @@ const isFree = computed<boolean>(() => props.plan?.type === PricingPlanType.FREE
184194
185195
const upgradePayUpfrontAmount = computed(() => configStore.state.config.upgradePayUpfrontAmount);
186196
197+
const collectBillingInfo = computed(() => configStore.state.config.collectBillingInfoOnOnboarding && props.isAccountSetup);
198+
187199
function onBack(): void {
188200
stripeReady.value = false;
189201
emit('back');
@@ -201,36 +213,61 @@ async function onActivateClick() {
201213
}
202214
203215
if (!stripeCardInput.value) return;
216+
if (collectBillingInfo.value && !stripeInfoForm.value) return;
217+
218+
const errorSource = props.isAccountSetup ? AnalyticsErrorEventSource.ACCOUNT_SETUP_DIALOG : AnalyticsErrorEventSource.UPGRADE_ACCOUNT_MODAL;
219+
220+
let info: PurchaseBillingInfo | undefined;
221+
let token = '';
204222
205223
loading.value = true;
224+
225+
try {
226+
if (collectBillingInfo.value && stripeInfoForm.value) {
227+
info = await stripeInfoForm.value.onSubmit();
228+
}
229+
230+
token = await stripeCardInput.value.onSubmit();
231+
} catch (error) {
232+
notify.notifyError(error, errorSource);
233+
loading.value = false;
234+
return;
235+
}
236+
206237
try {
207-
const response = await stripeCardInput.value.onSubmit();
208-
await onCardAdded(response);
238+
const request: PurchaseRequest = {
239+
token,
240+
intent: PurchaseIntent.PackagePlan,
241+
address: info?.address,
242+
tax: info?.tax,
243+
};
244+
245+
await onCardAdded(request);
209246
} catch (error) {
210-
const source = props.isAccountSetup ? AnalyticsErrorEventSource.ACCOUNT_SETUP_DIALOG : AnalyticsErrorEventSource.UPGRADE_ACCOUNT_MODAL;
211-
notify.notifyError(error, source);
247+
notify.notifyError(error, errorSource);
212248
213249
// initStripe will get a new card setup secret if there's an error.
214250
stripeCardInput.value?.initStripe();
215251
}
252+
216253
loading.value = false;
217254
}
218255
219256
/**
220257
* Adds card after Stripe confirmation.
221-
* @param res - the response from stripe. Could be a token or a payment method id.
222-
* depending on the paymentElementEnabled flag.
258+
* @param request - Purchase request info.
223259
*/
224-
async function onCardAdded(res: string): Promise<void> {
260+
async function onCardAdded(request: PurchaseRequest): Promise<void> {
225261
if (!props.plan) return;
226262
227263
if (props.plan.type === PricingPlanType.PARTNER) {
228-
await billingStore.purchasePricingPackage(res);
264+
await billingStore.purchasePricingPackage(request);
229265
} else {
230266
if (upgradePayUpfrontAmount.value > 0) {
231-
await billingStore.purchaseUpgradedAccount(res);
267+
request.intent = PurchaseIntent.UpgradeAccount;
268+
await billingStore.purchaseUpgradedAccount(request);
232269
} else {
233-
await billingStore.addCardByPaymentMethodID(res);
270+
await billingStore.addCardByPaymentMethodID(request.token);
234271
}
235272
}
236273
onSuccess();

web/satellite/src/store/modules/billingStore.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
PaymentWithConfirmations,
2222
PriceModelForPlacementRequest,
2323
ProductCharges,
24-
PurchaseIntent,
24+
PurchaseRequest,
2525
Tax,
2626
TaxCountry,
2727
UpdateCardParams,
@@ -328,12 +328,12 @@ export const useBillingStore = defineStore('billing', () => {
328328
return await api.pricingPackageAvailable();
329329
}
330330

331-
async function purchasePricingPackage(dataStr: string): Promise<void> {
332-
await api.purchase(dataStr, PurchaseIntent.PackagePlan, csrfToken.value);
331+
async function purchasePricingPackage(request: PurchaseRequest): Promise<void> {
332+
await api.purchase(request, csrfToken.value);
333333
}
334334

335-
async function purchaseUpgradedAccount(dataStr: string): Promise<void> {
336-
await api.purchase(dataStr, PurchaseIntent.UpgradeAccount, csrfToken.value);
335+
async function purchaseUpgradedAccount(request: PurchaseRequest): Promise<void> {
336+
await api.purchase(request, csrfToken.value);
337337
}
338338

339339
function setPricingPlansAvailable(available: boolean, info: PricingPlanInfo | null = null): void {

0 commit comments

Comments
 (0)