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 @@
+
+
+
+ {{ flowHeading }}
+ {{ flowDescription }}
+
+
+
+
+
+
+
+ {{ serviceHeading }}
+ {{ selectedFlows.length ? serviceDescription : emptyServiceDescription }}
+
+
+
+
+ {{ flow.icon }}
+ {{ flow.title }}
+
+
+
+
+
+
+ mdi-cursor-default-click-outline
+ {{ emptyText }}
+
+
+
+
+
+
+
+
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')
+ })
+})