diff --git a/Changelog.md b/Changelog.md index d7d08209..d7a3c9e3 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,10 @@ ## [Unreleased] +## [3.16.0] - 2026-10-09 +### Added +- Added Id service credit calculator + ## [3.15.6] - 2026-10-2 ### Changed diff --git a/package.json b/package.json index 604ef83f..29fd1fc1 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,12 @@ "scripts": { "serve": "vue-cli-service serve --mode production", "dev": "vue-cli-service serve --mode staging", + "catalog:cache": "node scripts/cache-kyc-catalog.js", + "catalog:refresh": "node scripts/cache-kyc-catalog.js --refresh", + "prebuild": "npm run catalog:cache", "setEnv": "cp .env.staging .env", "build": "vue-cli-service build", + "prebuild-stage": "npm run catalog:cache", "build-stage": "vue-cli-service build --mode staging", "lint": "vue-cli-service lint", "test:unit": "vue-cli-service test:unit", diff --git a/scripts/cache-kyc-catalog.js b/scripts/cache-kyc-catalog.js new file mode 100644 index 00000000..090f2c5d --- /dev/null +++ b/scripts/cache-kyc-catalog.js @@ -0,0 +1,57 @@ +const fs = require('fs'); +const https = require('https'); +const path = require('path'); + +const catalogUrl = 'https://raw.githubusercontent.com/hypersign-protocol/hypersign-credits-middleware/refs/heads/ADR000/src/catalogs/catalog.kyc.json'; +const catalogPath = path.resolve(__dirname, '../src/catalogs/catalog.kyc.json'); +const shouldRefresh = process.argv.includes('--refresh'); + +function hasValidCache() { + if (!fs.existsSync(catalogPath)) return false; + + try { + const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8')); + return Array.isArray(catalog.routes) && catalog.routes.length > 0; + } catch (_) { + return false; + } +} + +if (!shouldRefresh && hasValidCache()) { + console.log(`Using cached KYC credit catalog at ${catalogPath}`); + process.exit(0); +} + +fs.mkdirSync(path.dirname(catalogPath), { recursive: true }); + +https.get(catalogUrl, response => { + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + console.error('The KYC catalog URL redirected. Update the pinned catalog URL before building.'); + process.exit(1); + } + + if (response.statusCode !== 200) { + console.error(`Unable to download KYC catalog: HTTP ${response.statusCode}`); + process.exit(1); + } + + let body = ''; + response.setEncoding('utf8'); + response.on('data', chunk => { body += chunk; }); + response.on('end', () => { + try { + const catalog = JSON.parse(body); + if (!Array.isArray(catalog.routes) || catalog.routes.length === 0) { + throw new Error('catalog does not contain routes'); + } + fs.writeFileSync(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); + console.log(`Cached KYC credit catalog at ${catalogPath}`); + } catch (error) { + console.error(`Unable to cache KYC catalog: ${error.message}`); + process.exit(1); + } + }); +}).on('error', error => { + console.error(`Unable to download KYC catalog: ${error.message}`); + process.exit(1); +}); diff --git a/src/catalogs/catalog.kyc.json b/src/catalogs/catalog.kyc.json new file mode 100644 index 00000000..e3414409 --- /dev/null +++ b/src/catalogs/catalog.kyc.json @@ -0,0 +1,558 @@ +{ + "serviceType": "CAVACH_API", + "version": "3.23.0", + "globalPrefix": "api", + "versioning": "URI", + "uriVersionPrefix": "v", + "defaultVersion": "1", + "routes": [ + { + "method": "POST", + "path": "/api/v1/aadhaar/face/match", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 8 + } + ] + }, + { + "method": "POST", + "path": "/api/v1/aadhaar/otp/generate", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 2 + } + ] + }, + { + "method": "POST", + "path": "/api/v1/aadhaar/otp/verify", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 5 + } + ] + }, + { + "method": "POST", + "path": "/api/v1/aadhaar/qr/verify", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 7 + } + ] + }, + { + "method": "GET", + "path": "/api/v1/compliance", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/compliance", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 100 + } + ] + }, + { + "method": "GET", + "path": "/api/v1/credit", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/credit", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/credit/:creditId", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/credit/:creditId/activate", + "charges": [] + }, + { + "method": "DELETE", + "path": "/api/v1/document/:id", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/document/:id/verification", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 20 + } + ] + }, + { + "method": "GET", + "path": "/api/v1/document/download/:id", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/document/upload", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 10 + } + ] + }, + { + "method": "GET", + "path": "/api/v1/e-kyb/verification/company", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyb/verification/company", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 100 + } + ] + }, + { + "method": "GET", + "path": "/api/v1/e-kyb/verification/company/:companyId/company-executives", + "charges": [] + }, + { + "method": "PATCH", + "path": "/api/v1/e-kyb/verification/company/:companyId/company-executives", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyb/verification/company/:companyId/company-executives", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 1 + } + ] + }, + { + "method": "DELETE", + "path": "/api/v1/e-kyb/verification/company/:companyId/company-executives/:id", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyb/verification/company/:companyId/company-executives/:id", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyb/verification/company/:companyId/company-executives/resend-mail", + "charges": [] + }, + { + "method": "DELETE", + "path": "/api/v1/e-kyb/verification/company/:id", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyb/verification/company/:id", + "charges": [] + }, + { + "method": "PATCH", + "path": "/api/v1/e-kyb/verification/company/:id", + "charges": [] + }, + { + "method": "PATCH", + "path": "/api/v1/e-kyb/verification/company/:id/status", + "charges": [] + }, + { + "method": "DELETE", + "path": "/api/v1/e-kyb/verification/widget-config", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyb/verification/widget-config", + "charges": [] + }, + { + "method": "PATCH", + "path": "/api/v1/e-kyb/verification/widget-config", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyb/verification/widget-config", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/analytics/demographic-stats", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/analytics/device-stats", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/analytics/overview", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/asset-metadata", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/auth", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 1 + } + ] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/doc-ocr", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 30 + } + ] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/doc-ocr/extract", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 30 + } + ] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/doc-ocr/status/:sessionId", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/mobile-flow", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/mobile-flow/:flowId", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 10 + } + ] + }, + { + "method": "PATCH", + "path": "/api/v1/e-kyc/verification/mobile-flow/:flowId", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/mobile-flow/:flowId/status", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/onchainkyc-config", + "charges": [] + }, + { + "method": "PATCH", + "path": "/api/v1/e-kyc/verification/onchainkyc-config", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/onchainkyc-config", + "charges": [] + }, + { + "method": "DELETE", + "path": "/api/v1/e-kyc/verification/onchainkyc-config/:onchainConfigId", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/onchainkyc-config/:onchainConfigId", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/passive-liveliness", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 15 + } + ] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/passive-liveliness/status/:sessionId", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/sbt-mint", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/session", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/session", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 4 + } + ] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/session/:sessionId", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/sessions/:sessionId/decision", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 4 + } + ] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/user-consent", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/user-consent", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 5 + } + ] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/webhook-config", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/webhook-config", + "charges": [] + }, + { + "method": "DELETE", + "path": "/api/v1/e-kyc/verification/webhook-config/:id", + "charges": [] + }, + { + "method": "PATCH", + "path": "/api/v1/e-kyc/verification/webhook-config/:id", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/widget-config", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/widget-config", + "charges": [] + }, + { + "method": "DELETE", + "path": "/api/v1/e-kyc/verification/widget-config/:widgetConfigId", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/widget-config/:widgetConfigId", + "charges": [] + }, + { + "method": "PATCH", + "path": "/api/v1/e-kyc/verification/widget-config/:widgetConfigId", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/e-kyc/verification/widget-config/all", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/e-kyc/verification/zk-proof", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 10 + } + ] + }, + { + "method": "POST", + "path": "/api/v1/mail-client/add-job", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/proof", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/usage", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/usage/detail", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/usage/invoice", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/user", + "charges": [] + }, + { + "method": "GET", + "path": "/api/v1/user/:userId", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v1/wallet", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v2/auth/exchange", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 1 + } + ] + }, + { + "method": "POST", + "path": "/api/v2/biometrics/verify", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 45 + } + ] + }, + { + "method": "GET", + "path": "/api/v2/claims/consent", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v2/consents", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 5 + } + ] + }, + { + "method": "GET", + "path": "/api/v2/consents/:sessionId", + "charges": [] + }, + { + "method": "POST", + "path": "/api/v2/documents/extract", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 20 + } + ] + }, + { + "method": "POST", + "path": "/api/v2/session", + "charges": [ + { + "id": "api", + "creditType": "API_CREDIT", + "amount": 4 + } + ] + } + ] +} diff --git a/src/components/credit/KycCreditCalculator.vue b/src/components/credit/KycCreditCalculator.vue new file mode 100644 index 00000000..866b3210 --- /dev/null +++ b/src/components/credit/KycCreditCalculator.vue @@ -0,0 +1,599 @@ + + + + + + + diff --git a/src/components/credit/VerificationFlowConfigurator.vue b/src/components/credit/VerificationFlowConfigurator.vue new file mode 100644 index 00000000..349d2bdb --- /dev/null +++ b/src/components/credit/VerificationFlowConfigurator.vue @@ -0,0 +1,449 @@ + + + + + diff --git a/src/store/creditCalculatorStore.js b/src/store/creditCalculatorStore.js new file mode 100644 index 00000000..14390f23 --- /dev/null +++ b/src/store/creditCalculatorStore.js @@ -0,0 +1,49 @@ +const emptyConfiguration = () => ({ + selectedFlowIds: [], + selectedServiceIds: [], + serviceQuantities: {}, + creditsPerVerification: 0, + updatedAt: null +}) + +const uniqueStrings = (values) => Array.from(new Set( + (Array.isArray(values) ? values : []).filter(value => typeof value === 'string') +)) + +const creditCalculatorStore = { + namespaced: true, + state: { + configuration: emptyConfiguration() + }, + getters: { + getConfiguration: (state) => state.configuration + }, + mutations: { + saveConfiguration(state, payload = {}) { + const selectedServiceIds = uniqueStrings(payload.selectedServiceIds) + const rawQuantities = payload.serviceQuantities && typeof payload.serviceQuantities === 'object' + ? payload.serviceQuantities + : {} + const serviceQuantities = Object.keys(rawQuantities).reduce((quantities, serviceId) => { + if (!selectedServiceIds.includes(serviceId)) return quantities + quantities[serviceId] = Math.max(Math.floor(Number(rawQuantities[serviceId]) || 1), 1) + return quantities + }, {}) + + state.configuration = { + selectedFlowIds: uniqueStrings(payload.selectedFlowIds), + selectedServiceIds, + serviceQuantities, + creditsPerVerification: Number.isFinite(Number(payload.creditsPerVerification)) + ? Math.max(Number(payload.creditsPerVerification), 0) + : 0, + updatedAt: new Date().toISOString() + } + }, + clearConfiguration(state) { + state.configuration = emptyConfiguration() + } + } +} + +export default creditCalculatorStore diff --git a/src/store/index.js b/src/store/index.js index b3133679..a649a45a 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -4,6 +4,7 @@ import playgroundStore from './playgroundStore'; import mainStore from './mainStore'; import globalStore from './globalStore'; import walletStore from './walletStore' +import creditCalculatorStore from './creditCalculatorStore' import createPersistedState from 'vuex-persistedstate'; Vue.use(Vuex) @@ -12,8 +13,8 @@ export default new Vuex.Store({ playgroundStore, mainStore, globalStore, - walletStore + walletStore, + creditCalculatorStore }, plugins: [createPersistedState()] }) - diff --git a/src/views/playground/KYCDashboardCredit.vue b/src/views/playground/KYCDashboardCredit.vue index 7fb2841a..2da95150 100644 --- a/src/views/playground/KYCDashboardCredit.vue +++ b/src/views/playground/KYCDashboardCredit.vue @@ -50,7 +50,27 @@ / {{ numberFormat(myKYCCredits.allAvailableCredits) }} Credits -

upto ~{{ numberFormat(approxKYCLeftBestCase) }} ID Verifications

+
@@ -156,6 +176,11 @@ + @@ -165,10 +190,11 @@ import { mapActions, mapGetters } from "vuex"; import UtilsMixin from '../../mixins/utils'; import HfButtons from "../../components/element/HfButtons.vue"; import AccessDenied from '../AccessDenied.vue'; +import KycCreditCalculator from '../../components/credit/KycCreditCalculator.vue'; export default { - name: "SSIDashboardCredit", - components: { HfButtons, AccessDenied }, + name: "KYCDashboardCredit", + components: { HfButtons, AccessDenied, KycCreditCalculator }, mixins: [UtilsMixin], data() { return { @@ -180,7 +206,7 @@ export default { accessDeniedMsg: '', fullPage: true, doughNutChartLabel: ['Used', 'Remaining'], - analyticsOverview: null, + calculatorOpen: false, } }, computed: { @@ -197,23 +223,21 @@ export default { return 'color-green'; }, - // Formula: (1/completionRate) accounts for sessions that don't complete - // + dropOffRate adds waste from abandoned sessions - // Example: 70% completion, 24% drop-off → (1/0.7) + 0.24 = 1.67 → ~125 credits/ID - costPerKYC() { - if (!this.analyticsOverview) return 75; // no data yet: assume best case - const completionRate = Math.max((this.analyticsOverview.completionRate || 100), 1) / 100; - const dropOff = (this.analyticsOverview.dropOffRate || 0) / 100; - const multiplier = Math.min(Math.max((1 / completionRate) + dropOff, 1.0), 2.5); - return Math.ceil(75 * multiplier); + savedCalculatorConfiguration() { + return this.$store.getters['creditCalculatorStore/getConfiguration'] || {}; }, - approxKYCLeft() { - return Math.floor((this.myKYCCredits.allRemainingCredits || 0) / this.costPerKYC); + hasSavedCalculatorConfiguration() { + const configuration = this.savedCalculatorConfiguration; + return Array.isArray(configuration.selectedFlowIds) + && configuration.selectedFlowIds.length > 0 + && Number(configuration.creditsPerVerification) > 0; }, - approxKYCLeftBestCase() { - return Math.floor((this.myKYCCredits.allRemainingCredits || 0) / 75); + savedVerificationEstimate() { + const cost = Number(this.savedCalculatorConfiguration.creditsPerVerification || 0); + if (!cost) return 0; + return Math.floor(Math.max(this.myKYCCredits.allRemainingCredits, 0) / cost); }, myKYCCredits() { @@ -253,18 +277,13 @@ export default { this.stopTimer(); }, methods: { - ...mapActions('mainStore', ['fetchKYCCredits', 'activateCredit', 'fetchAnalyticsOverview']), + ...mapActions('mainStore', ['fetchKYCCredits', 'activateCredit']), async reloadData() { try { this.isLoading = true; await this.fetchKYCCredits(); - // Non-blocking: fetch analytics to refine estimate - this.fetchAnalyticsOverview({ env: this.$store.getters['mainStore/getSelectedService']?.env || 'prod' }) - .then(res => { if (res?.data) this.analyticsOverview = res.data; }) - .catch(() => {}); - this.startTimer(); this.renderChart(); } catch (e) { @@ -458,9 +477,74 @@ export default { /* Keeps label close to the value below it */ } -.approx-inline { - font-size: 0.75rem; - color: #9ca3af; +.calculator-callout { + display: grid; + width: 100%; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.85rem; + align-items: center; + padding: 0.75rem 0.9rem; + border: 1px solid #d5dde7; + border-radius: 0.65rem; + background: #f8fafc; + color: #495057; + text-align: left; + transition: border-color .15s ease, background-color .15s ease; +} + +.calculator-callout:hover { + border-color: #94a3b8; + background: #f3f4f5; +} + +.calculator-callout:focus-visible { + outline: 3px solid rgba(108, 117, 125, .2); + outline-offset: 2px; +} + +.calculator-callout-icon { + display: inline-flex; + width: 2.5rem; + height: 2.5rem; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #eceeef; +} + +.calculator-callout-icon .v-icon, +.calculator-callout-action, +.calculator-callout-action .v-icon { + color: #6c757d; +} + +.calculator-callout-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.calculator-callout-copy strong { + color: #253454; + font-size: 0.86rem; +} + +.calculator-callout-copy small { + margin-top: 0.1rem; + color: #64748b; + font-size: 0.76rem; +} + +.calculator-callout-action { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.7rem 0.85rem; + border-radius: 0.55rem; + background: #e4e7e9; + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; } /* Ensure the button doesn't have extra margin pushing it away from the edge */ @@ -490,4 +574,27 @@ export default { vertical-align: middle; } - \ No newline at end of file +@media (max-width: 767.98px) { + .py-3 { + width: 100% !important; + } + + .calculator-callout { + grid-template-columns: auto minmax(0, 1fr); + } + + .calculator-callout-action { + grid-column: 2; + justify-self: start; + } + + .usage-table-wrapper { + overflow-x: auto; + } + + .usage-table { + min-width: 760px; + } +} + + diff --git a/tests/unit/KycCreditCalculator.spec.js b/tests/unit/KycCreditCalculator.spec.js new file mode 100644 index 00000000..8c64ec77 --- /dev/null +++ b/tests/unit/KycCreditCalculator.spec.js @@ -0,0 +1,216 @@ +import { expect } from 'chai' +import { shallowMount } from '@vue/test-utils' +import KycCreditCalculator from '@/components/credit/KycCreditCalculator.vue' + +const mountCalculator = (options = {}) => shallowMount(KycCreditCalculator, { + propsData: { + remainingCredits: 8779, + totalCredits: 10100 + }, + ...options +}) + +describe('KycCreditCalculator.vue', () => { + it('selects the India defaults without making either service mandatory', () => { + const wrapper = mountCalculator() + + wrapper.vm.updateSelection({ + selectedFlowIds: ['india'], + selectedServiceIds: ['aadhaar-face-match', 'aadhaar-verification'] + }) + + expect(wrapper.vm.selectedFlowIds).to.deep.equal(['india']) + expect(wrapper.vm.selectedServiceIds).to.include.members([ + 'aadhaar-face-match', + 'aadhaar-verification' + ]) + expect(wrapper.vm.flows[0].services.every(service => !service.required)).to.equal(true) + }) + + it('selects the Global defaults but leaves Age Verification opt-in', () => { + const wrapper = mountCalculator() + + wrapper.vm.updateSelection({ + selectedFlowIds: ['global'], + selectedServiceIds: ['verification-session', 'passive-liveness', 'document-verification', 'consent-capture'] + }) + + expect(wrapper.vm.selectedServiceIds).to.include.members([ + 'verification-session', + 'passive-liveness', + 'document-verification', + 'consent-capture' + ]) + expect(wrapper.vm.serviceCost(wrapper.vm.flows[1].services[0])).to.equal(5) + expect(wrapper.vm.serviceCost(wrapper.vm.flows[1].services[2])).to.equal(40) + expect(wrapper.vm.selectedServiceIds).not.to.include('age-verification') + }) + + it('selects all Business services and adds the dependent Global defaults for UBO', () => { + const wrapper = mountCalculator() + + wrapper.vm.updateSelection({ + selectedFlowIds: ['global', 'business'], + selectedServiceIds: [ + 'verification-session', + 'passive-liveness', + 'document-verification', + 'consent-capture', + 'business-data-capture', + 'ubo-verification', + 'aml-screening' + ], + serviceQuantities: { 'ubo-verification': 1 } + }) + + expect(wrapper.vm.selectedFlowIds).to.include.members(['business', 'global']) + expect(wrapper.vm.selectedServiceIds).to.include.members([ + 'business-data-capture', + 'ubo-verification', + 'aml-screening', + 'verification-session', + 'passive-liveness', + 'document-verification', + 'consent-capture' + ]) + expect(wrapper.vm.flows[2].services.find(service => service.id === 'ubo-verification').requiresFlowIds).to.include('global') + expect(wrapper.vm.creditsPerVerification).to.equal(266) + expect(wrapper.vm.estimatedVerifications).to.equal(33) + }) + + it('multiplies UBO and selected Global services by the UBO quantity', () => { + const wrapper = mountCalculator() + + wrapper.vm.updateSelection({ + selectedFlowIds: ['global', 'business'], + selectedServiceIds: [ + 'verification-session', + 'passive-liveness', + 'document-verification', + 'consent-capture', + 'business-data-capture', + 'ubo-verification', + 'aml-screening' + ], + serviceQuantities: { 'ubo-verification': 3 } + }) + + expect(wrapper.vm.creditsPerVerification).to.equal(398) + expect(wrapper.vm.estimatedVerifications).to.equal(22) + expect(wrapper.vm.configurationSummary.title).to.equal('Up to 22 Business verifications') + expect(wrapper.vm.configurationSummary.detail).to.equal('Each business verification includes 3 UBO verifications.') + }) + + it('summarizes a standalone identity flow using its selected services', () => { + const wrapper = mountCalculator() + wrapper.vm.updateSelection({ + selectedFlowIds: ['india'], + selectedServiceIds: ['aadhaar-verification'] + }) + + expect(wrapper.vm.configurationSummary.title).to.include('India Identity verifications') + expect(wrapper.vm.configurationSummary.detail).to.equal('1 selected service per verification.') + }) + + it('prevents the next UBO quantity from exceeding available credits', () => { + const wrapper = mountCalculator({ + propsData: { remainingCredits: 397, totalCredits: 10100 } + }) + wrapper.vm.updateSelection({ + selectedFlowIds: ['global', 'business'], + selectedServiceIds: [ + 'verification-session', + 'passive-liveness', + 'document-verification', + 'consent-capture', + 'business-data-capture', + 'ubo-verification', + 'aml-screening' + ], + serviceQuantities: { 'ubo-verification': 2 } + }) + const ubo = wrapper.vm.flows[2].services.find(service => service.id === 'ubo-verification') + + expect(wrapper.vm.creditsPerVerification).to.equal(332) + expect(wrapper.vm.canIncreaseServiceQuantity(ubo, 3)).to.equal(false) + }) + + it('keeps every Business service optional', () => { + const wrapper = mountCalculator() + const businessServices = wrapper.vm.flows[2].services + + expect(businessServices.every(service => !service.required)).to.equal(true) + }) + + it('saves the selected configuration to the calculator store', () => { + const commits = [] + const wrapper = mountCalculator({ + mocks: { + $store: { + getters: { 'creditCalculatorStore/getConfiguration': null }, + commit: (type, payload) => commits.push({ type, payload }) + } + } + }) + wrapper.vm.updateSelection({ + selectedFlowIds: ['india'], + selectedServiceIds: ['aadhaar-face-match', 'aadhaar-verification'] + }) + + wrapper.vm.saveConfiguration() + + expect(commits).to.have.length(1) + expect(commits[0].type).to.equal('creditCalculatorStore/saveConfiguration') + expect(commits[0].payload.selectedFlowIds).to.deep.equal(['india']) + expect(commits[0].payload.selectedServiceIds).to.include.members([ + 'aadhaar-face-match', + 'aadhaar-verification' + ]) + expect(commits[0].payload.serviceQuantities).to.deep.equal({}) + expect(commits[0].payload.creditsPerVerification).to.equal(wrapper.vm.creditsPerVerification) + }) + + it('restores an empty Business selection without adding mandatory services', () => { + const wrapper = mountCalculator({ + mocks: { + $store: { + getters: { + 'creditCalculatorStore/getConfiguration': { + selectedFlowIds: ['business'], + selectedServiceIds: [] + } + }, + commit: () => {} + } + } + }) + + wrapper.vm.loadSavedConfiguration() + + expect(wrapper.vm.selectedFlowIds).to.deep.equal(['business']) + expect(wrapper.vm.selectedServiceIds).to.deep.equal([]) + }) + + it('restores Global only when the saved UBO service needs it', () => { + const wrapper = mountCalculator({ + mocks: { + $store: { + getters: { + 'creditCalculatorStore/getConfiguration': { + selectedFlowIds: ['business'], + selectedServiceIds: ['ubo-verification'], + serviceQuantities: { 'ubo-verification': 4 } + } + }, + commit: () => {} + } + } + }) + + wrapper.vm.loadSavedConfiguration() + + expect(wrapper.vm.selectedFlowIds).to.deep.equal(['global', 'business']) + expect(wrapper.vm.selectedServiceIds).to.deep.equal(['ubo-verification']) + expect(wrapper.vm.serviceQuantities).to.deep.equal({ 'ubo-verification': 4 }) + }) +}) diff --git a/tests/unit/VerificationFlowConfigurator.spec.js b/tests/unit/VerificationFlowConfigurator.spec.js new file mode 100644 index 00000000..7b23d7cb --- /dev/null +++ b/tests/unit/VerificationFlowConfigurator.spec.js @@ -0,0 +1,131 @@ +import { expect } from 'chai' +import { shallowMount } from '@vue/test-utils' +import VerificationFlowConfigurator from '@/components/credit/VerificationFlowConfigurator.vue' + +const flows = [ + { + id: 'global', + title: 'Global', + description: 'Global flow', + icon: 'mdi-earth', + services: [ + { id: 'session', title: 'Session', description: 'Session', icon: 'mdi-cellphone', required: true }, + { id: 'document', title: 'Document', description: 'Document', icon: 'mdi-file', defaultSelected: true } + ] + }, + { + id: 'business', + title: 'Business', + description: 'Business flow', + icon: 'mdi-domain', + services: [ + { + id: 'ubo', + title: 'UBO', + description: 'UBO', + icon: 'mdi-account-group', + required: true, + quantitySelectable: true, + defaultQuantity: 1, + requiresFlowIds: ['global'] + } + ] + } +] + +const mountConfigurator = (value = { selectedFlowIds: [], selectedServiceIds: [] }) => shallowMount( + VerificationFlowConfigurator, + { propsData: { flows, value } } +) + +describe('VerificationFlowConfigurator.vue', () => { + it('emits defaults and dependent flows from configuration data', () => { + const wrapper = mountConfigurator() + + wrapper.vm.selectFlow('business') + + const selection = wrapper.emitted('input')[0][0] + expect(selection.selectedFlowIds).to.include.members(['business', 'global']) + expect(selection.selectedServiceIds).to.include.members(['ubo', 'session', 'document']) + expect(selection.serviceQuantities).to.deep.equal({ ubo: 1 }) + }) + + it('increments a selected service quantity and does not decrement below its minimum', () => { + const value = { + selectedFlowIds: ['global', 'business'], + selectedServiceIds: ['session', 'document', 'ubo'], + serviceQuantities: { ubo: 1 } + } + const wrapper = mountConfigurator(value) + const ubo = flows[1].services[0] + + wrapper.vm.changeServiceQuantity(ubo, 1) + expect(wrapper.emitted('input')[0][0].serviceQuantities).to.deep.equal({ ubo: 2 }) + + wrapper.vm.changeServiceQuantity(ubo, -1) + expect(wrapper.emitted('input')[1][0].serviceQuantities).to.deep.equal({ ubo: 1 }) + }) + + it('blocks an increment when the configured quantity limit rejects it', () => { + const value = { + selectedFlowIds: ['global', 'business'], + selectedServiceIds: ['session', 'document', 'ubo'], + serviceQuantities: { ubo: 2 } + } + const wrapper = shallowMount(VerificationFlowConfigurator, { + propsData: { + flows, + value, + canIncreaseServiceQuantity: (service, quantity) => service.id !== 'ubo' || quantity <= 2 + } + }) + + wrapper.vm.changeServiceQuantity(flows[1].services[0], 1) + expect(wrapper.emitted('input')).to.equal(undefined) + }) + + it('accepts a typed quantity and caps it at the highest allowed value', () => { + const value = { + selectedFlowIds: ['global', 'business'], + selectedServiceIds: ['session', 'document', 'ubo'], + serviceQuantities: { ubo: 1 } + } + const wrapper = shallowMount(VerificationFlowConfigurator, { + propsData: { + flows, + value, + canIncreaseServiceQuantity: (service, quantity) => service.id !== 'ubo' || quantity <= 3 + } + }) + + wrapper.vm.setServiceQuantity(flows[1].services[0], '20') + expect(wrapper.emitted('input')[0][0].serviceQuantities).to.deep.equal({ ubo: 3 }) + }) + + it('allows optional services to toggle but rejects required services', () => { + const wrapper = mountConfigurator({ + selectedFlowIds: ['global'], + selectedServiceIds: ['session', 'document'] + }) + + wrapper.vm.toggleService(flows[0].services[1]) + expect(wrapper.emitted('input')[0][0].selectedServiceIds).not.to.include('document') + + wrapper.vm.toggleService(flows[0].services[0]) + expect(wrapper.emitted('input')).to.have.length(1) + }) + + it('supports pricing reuse without rendering service costs', () => { + const wrapper = shallowMount(VerificationFlowConfigurator, { + propsData: { + flows, + value: { selectedFlowIds: ['global'], selectedServiceIds: ['session'] }, + showServiceCosts: false, + flowHeading: 'Configure your plan' + } + }) + + expect(wrapper.text()).to.include('Configure your plan') + expect(wrapper.find('.credit-pill').exists()).to.equal(false) + }) +}) diff --git a/tests/unit/creditCalculatorStore.spec.js b/tests/unit/creditCalculatorStore.spec.js new file mode 100644 index 00000000..9c17a5ac --- /dev/null +++ b/tests/unit/creditCalculatorStore.spec.js @@ -0,0 +1,23 @@ +import { expect } from 'chai' +import creditCalculatorStore from '@/store/creditCalculatorStore' + +describe('creditCalculatorStore', () => { + it('stores isolated copies of the calculator selection', () => { + const state = { configuration: {} } + const payload = { + selectedFlowIds: ['india', 'india'], + selectedServiceIds: ['aadhaar-verification'], + serviceQuantities: { 'aadhaar-verification': 2, ignored: 5 }, + creditsPerVerification: 12 + } + + creditCalculatorStore.mutations.saveConfiguration(state, payload) + payload.selectedFlowIds.push('global') + + expect(state.configuration.selectedFlowIds).to.deep.equal(['india']) + expect(state.configuration.selectedServiceIds).to.deep.equal(['aadhaar-verification']) + expect(state.configuration.serviceQuantities).to.deep.equal({ 'aadhaar-verification': 2 }) + expect(state.configuration.creditsPerVerification).to.equal(12) + expect(state.configuration.updatedAt).to.be.a('string') + }) +})