From 677f8a5d7994014e6f22f074d520e045dd11d214 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Tue, 21 Jul 2026 17:44:41 +0200 Subject: [PATCH] feat: customer trust scores --- .../backend-agent-controller/Dockerfile.api | 2 + .../backend-agent-controller/package.json | 3 +- .../backend-agent-manager/Dockerfile.agi | 2 + .../backend-agent-manager/Dockerfile.api | 2 + .../backend-agent-manager/Dockerfile.worker | 2 + .../backend-agent-manager/package.json | 3 +- .../frontend-agent-console/Dockerfile.server | 3 + .../frontend-landingpage/Dockerfile.server | 3 + .../backend-billing-manager/Dockerfile.api | 2 + .../backend-billing-manager/package.json | 3 +- ...4400000000_AddCustomerProfileTrustScore.ts | 37 ++ .../Dockerfile.server | 3 + .../src/i18n/messages.de.xlf | 44 ++ .../src/i18n/messages.xlf | 33 ++ .../frontend-landingpage/Dockerfile.server | 3 + .../backend-communication/Dockerfile.api | 2 + apps/forepath/frontend-landingpage/Dockerfile | 3 + .../frontend-landingpage/Dockerfile.server | 3 + apps/shared/frontend-docs/Dockerfile.server | 3 + docs/decabill/features/README.md | 10 +- .../features/billing-administration.md | 19 +- docs/decabill/features/customer-profiles.md | 2 + .../decabill/features/customer-trust-score.md | 98 +++++ docs/decabill/features/webhooks.md | 2 + graph/graph.json | 387 +++++++++++++++++- .../backend/feature-billing-manager/README.md | 1 + .../feature-billing-manager/spec/openapi.yaml | 70 ++++ .../feature-billing-manager/src/index.ts | 6 + .../src/lib/billing.module.ts | 11 + .../admin-customer-profiles.controller.ts | 18 +- .../src/lib/dto/admin-customer-profile.dto.ts | 21 + .../src/lib/dto/customer-trust-score.dto.ts | 21 + .../lib/entities/customer-profile.entity.ts | 11 + .../billing-notification.events.spec.ts | 1 + .../billing-notification.events.ts | 1 + .../lib/repositories/backorders.repository.ts | 12 + .../lib/repositories/invoices.repository.ts | 25 ++ .../payment-attempts.repository.ts | 28 ++ .../repositories/subscriptions.repository.ts | 12 + .../lib/services/auto-billing.service.spec.ts | 4 + .../src/lib/services/auto-billing.service.ts | 7 + .../customer-profiles-admin.service.spec.ts | 43 ++ .../customer-profiles-admin.service.ts | 53 ++- .../services/invoice-admin.service.spec.ts | 4 + .../src/lib/services/invoice-admin.service.ts | 6 + .../services/invoice-issuance.service.spec.ts | 4 + .../lib/services/invoice-issuance.service.ts | 4 + .../src/lib/services/invoice.service.spec.ts | 4 + .../src/lib/services/invoice.service.ts | 3 + .../payment-orchestration.service.spec.ts | 4 + .../services/payment-orchestration.service.ts | 4 + .../lib/services/subscription.service.spec.ts | 4 + .../src/lib/services/subscription.service.ts | 6 + .../customer-trust-score.service.spec.ts | 152 +++++++ .../customer-trust-score.service.ts | 173 ++++++++ ...ernal-billing-trust-score.provider.spec.ts | 98 +++++ .../internal-billing-trust-score.provider.ts | 186 +++++++++ .../trust-score-provider.interface.ts | 6 + .../trust-score-provider.registry.ts | 16 + .../lib/trust-score/trust-score.constants.ts | 63 +++ .../src/lib/trust-score/trust-score.types.ts | 23 ++ .../admin-customer-profiles.service.spec.ts | 23 ++ .../admin-customer-profiles.service.ts | 12 + .../admin-customer-profiles.actions.ts | 27 ++ .../admin-customer-profiles.effects.spec.ts | 58 +++ .../admin-customer-profiles.effects.ts | 34 ++ .../admin-customer-profiles.facade.spec.ts | 14 + .../admin-customer-profiles.facade.ts | 16 + .../admin-customer-profiles.reducer.spec.ts | 59 +++ .../admin-customer-profiles.reducer.ts | 70 +++- .../admin-customer-profiles.selectors.spec.ts | 21 + .../admin-customer-profiles.selectors.ts | 15 + .../src/lib/types/billing.types.ts | 28 ++ ...dmin-customer-profiles-page.component.html | 123 ++++++ ...dmin-customer-profiles-page.component.scss | 38 ++ .../admin-customer-profiles-page.component.ts | 51 +++ .../src/lib/billing-console.routes.ts | 4 + .../src/lib/billing-status-labels.ts | 39 ++ ...reateUserPersonalAccessTokensTable.spec.ts | 38 -- package-lock.json | 213 ++-------- package.json | 3 +- 81 files changed, 2434 insertions(+), 231 deletions(-) create mode 100644 apps/decabill/backend-billing-manager/src/migrations/1774400000000_AddCustomerProfileTrustScore.ts create mode 100644 docs/decabill/features/customer-trust-score.md create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/customer-trust-score.dto.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/customer-trust-score.service.spec.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/customer-trust-score.service.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/internal-billing-trust-score.provider.spec.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/internal-billing-trust-score.provider.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score-provider.interface.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score-provider.registry.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score.constants.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score.types.ts delete mode 100644 libs/domains/identity/backend/util-auth/src/lib/migrations/1776100000000_CreateUserPersonalAccessTokensTable.spec.ts diff --git a/apps/agenstra/backend-agent-controller/Dockerfile.api b/apps/agenstra/backend-agent-controller/Dockerfile.api index c4b5154c4..776f2445b 100644 --- a/apps/agenstra/backend-agent-controller/Dockerfile.api +++ b/apps/agenstra/backend-agent-controller/Dockerfile.api @@ -59,6 +59,8 @@ RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | P . "${NVM_DIR}/nvm.sh" && \ nvm install "${NODE_VERSION}" && \ nvm alias default "${NODE_VERSION}" && \ + npm install -g npm@11.18.0 && \ + npm cache clean --force && \ chown -R agenstra:agenstra /home/agenstra COPY --chown=agenstra:agenstra package*.json ./ diff --git a/apps/agenstra/backend-agent-controller/package.json b/apps/agenstra/backend-agent-controller/package.json index 83ee11660..aff331ac3 100644 --- a/apps/agenstra/backend-agent-controller/package.json +++ b/apps/agenstra/backend-agent-controller/package.json @@ -45,6 +45,7 @@ "overrides": { "dockerode": { "protobufjs": "7.5.5" - } + }, + "tar": "7.5.19" } } diff --git a/apps/agenstra/backend-agent-manager/Dockerfile.agi b/apps/agenstra/backend-agent-manager/Dockerfile.agi index 9e7b30ed3..fb873dba9 100644 --- a/apps/agenstra/backend-agent-manager/Dockerfile.agi +++ b/apps/agenstra/backend-agent-manager/Dockerfile.agi @@ -42,6 +42,8 @@ RUN apt-get update && \ RUN HOME=/home/agenstra OPENCLAW_HOME=/openclaw \ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --no-onboard && \ + npm install -g npm@11.18.0 && \ + npm cache clean --force && \ chown -R agenstra:agenstra /openclaw /home/agenstra RUN printf '%s\n' \ diff --git a/apps/agenstra/backend-agent-manager/Dockerfile.api b/apps/agenstra/backend-agent-manager/Dockerfile.api index 26bb20f41..149cff8f2 100644 --- a/apps/agenstra/backend-agent-manager/Dockerfile.api +++ b/apps/agenstra/backend-agent-manager/Dockerfile.api @@ -59,6 +59,8 @@ RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | P . "${NVM_DIR}/nvm.sh" && \ nvm install "${NODE_VERSION}" && \ nvm alias default "${NODE_VERSION}" && \ + npm install -g npm@11.18.0 && \ + npm cache clean --force && \ chown -R agenstra:agenstra /home/agenstra COPY --chown=agenstra:agenstra package*.json ./ diff --git a/apps/agenstra/backend-agent-manager/Dockerfile.worker b/apps/agenstra/backend-agent-manager/Dockerfile.worker index ffeacaf37..3c20c7cfd 100644 --- a/apps/agenstra/backend-agent-manager/Dockerfile.worker +++ b/apps/agenstra/backend-agent-manager/Dockerfile.worker @@ -42,6 +42,8 @@ RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | P . "${NVM_DIR}/nvm.sh" && \ nvm install "${NODE_VERSION}" && \ nvm alias default "${NODE_VERSION}" && \ + npm install -g npm@11.18.0 && \ + npm cache clean --force && \ chown -R agenstra:agenstra /home/agenstra RUN . "${NVM_DIR}/nvm.sh" && npm install -g nx && npm cache clean --force && \ diff --git a/apps/agenstra/backend-agent-manager/package.json b/apps/agenstra/backend-agent-manager/package.json index 19664345e..191516103 100644 --- a/apps/agenstra/backend-agent-manager/package.json +++ b/apps/agenstra/backend-agent-manager/package.json @@ -41,6 +41,7 @@ "overrides": { "dockerode": { "protobufjs": "7.5.5" - } + }, + "tar": "7.5.19" } } diff --git a/apps/agenstra/frontend-agent-console/Dockerfile.server b/apps/agenstra/frontend-agent-console/Dockerfile.server index 77388dac6..2332fc3e6 100644 --- a/apps/agenstra/frontend-agent-console/Dockerfile.server +++ b/apps/agenstra/frontend-agent-console/Dockerfile.server @@ -22,6 +22,9 @@ WORKDIR /app EXPOSE ${PORT} +# CVE-2026-59873: upgrade npm so nested tar is >= 7.5.19 +RUN npm install -g npm@11.18.0 && npm cache clean --force + COPY --chown=${APP_UID}:${APP_GID} package*.json ./ RUN npm i --omit=dev && npm cache clean --force && chown -R "${APP_UID}:${APP_GID}" /app diff --git a/apps/agenstra/frontend-landingpage/Dockerfile.server b/apps/agenstra/frontend-landingpage/Dockerfile.server index 288adf8b5..5bea961d8 100644 --- a/apps/agenstra/frontend-landingpage/Dockerfile.server +++ b/apps/agenstra/frontend-landingpage/Dockerfile.server @@ -22,6 +22,9 @@ WORKDIR /app EXPOSE ${PORT} +# CVE-2026-59873: upgrade npm so nested tar is >= 7.5.19 +RUN npm install -g npm@11.18.0 && npm cache clean --force + COPY --chown=${APP_UID}:${APP_GID} . /app USER ${APP_UID}:${APP_GID} diff --git a/apps/decabill/backend-billing-manager/Dockerfile.api b/apps/decabill/backend-billing-manager/Dockerfile.api index af12bdc97..c3d4e43e1 100644 --- a/apps/decabill/backend-billing-manager/Dockerfile.api +++ b/apps/decabill/backend-billing-manager/Dockerfile.api @@ -47,6 +47,8 @@ RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | P . "${NVM_DIR}/nvm.sh" && \ nvm install "${NODE_VERSION}" && \ nvm alias default "${NODE_VERSION}" && \ + npm install -g npm@11.18.0 && \ + npm cache clean --force && \ chown -R agenstra:agenstra /home/agenstra COPY --chown=agenstra:agenstra package*.json ./ diff --git a/apps/decabill/backend-billing-manager/package.json b/apps/decabill/backend-billing-manager/package.json index eeb882ec2..8df6c56e8 100644 --- a/apps/decabill/backend-billing-manager/package.json +++ b/apps/decabill/backend-billing-manager/package.json @@ -51,6 +51,7 @@ "overrides": { "dockerode": { "protobufjs": "7.5.5" - } + }, + "tar": "7.5.19" } } diff --git a/apps/decabill/backend-billing-manager/src/migrations/1774400000000_AddCustomerProfileTrustScore.ts b/apps/decabill/backend-billing-manager/src/migrations/1774400000000_AddCustomerProfileTrustScore.ts new file mode 100644 index 000000000..e6a14ddc5 --- /dev/null +++ b/apps/decabill/backend-billing-manager/src/migrations/1774400000000_AddCustomerProfileTrustScore.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCustomerProfileTrustScore1774400000000 implements MigrationInterface { + name = 'AddCustomerProfileTrustScore1774400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "billing_customer_profiles" + ADD COLUMN IF NOT EXISTS "trust_score" integer + `); + await queryRunner.query(` + ALTER TABLE "billing_customer_profiles" + ADD COLUMN IF NOT EXISTS "trust_level" character varying(16) + `); + await queryRunner.query(` + ALTER TABLE "billing_customer_profiles" + ADD COLUMN IF NOT EXISTS "trust_score_updated_at" TIMESTAMP + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_billing_customer_profiles_trust_level" + ON "billing_customer_profiles" ("trust_level") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "idx_billing_customer_profiles_trust_level"`); + await queryRunner.query(` + ALTER TABLE "billing_customer_profiles" DROP COLUMN IF EXISTS "trust_score_updated_at" + `); + await queryRunner.query(` + ALTER TABLE "billing_customer_profiles" DROP COLUMN IF EXISTS "trust_level" + `); + await queryRunner.query(` + ALTER TABLE "billing_customer_profiles" DROP COLUMN IF EXISTS "trust_score" + `); + } +} diff --git a/apps/decabill/frontend-billing-console/Dockerfile.server b/apps/decabill/frontend-billing-console/Dockerfile.server index 5adbe603d..b58911e9c 100644 --- a/apps/decabill/frontend-billing-console/Dockerfile.server +++ b/apps/decabill/frontend-billing-console/Dockerfile.server @@ -22,6 +22,9 @@ WORKDIR /app EXPOSE ${PORT} +# CVE-2026-59873: upgrade npm so nested tar is >= 7.5.19 +RUN npm install -g npm@11.18.0 && npm cache clean --force + COPY --chown=${APP_UID}:${APP_GID} package*.json ./ RUN npm i --omit=dev && npm cache clean --force && chown -R "${APP_UID}:${APP_GID}" /app diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf index 91b1243b8..e9d50271d 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf @@ -338,10 +338,38 @@ Edit profile Profil bearbeiten + + View trust score + Vertrauensscore anzeigen + Delete profile Profil löschen + + Customer trust score + Kunden-Vertrauensscore + + + Trust score for + Vertrauensscore für + + + Base score: + Basisscore: + + + Computed: + Berechnet: + + + Trust details are currently unavailable. + Vertrauensdetails sind derzeit nicht verfügbar. + + + Recompute + Neu berechnen + Create billing profile Abrechnungsprofil erstellen @@ -362,6 +390,22 @@ This action cannot be undone. Diese Aktion kann nicht rückgängig gemacht werden. + + High trust + Hohes Vertrauen + + + Needs attention + Aufmerksamkeitsbedarf + + + High risk + Hohes Risiko + + + Not scored + Nicht bewertet + Contracts Verträge diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf index a9821e4ce..30fb80012 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf @@ -233,9 +233,30 @@ Edit profile + + View trust score + Delete profile + + Customer trust score + + + Trust score for + + + Base score: + + + Computed: + + + Trust details are currently unavailable. + + + Recompute + Create billing profile @@ -260,6 +281,18 @@ Delete + + High trust + + + Needs attention + + + High risk + + + Not scored + DATEV exports diff --git a/apps/decabill/frontend-landingpage/Dockerfile.server b/apps/decabill/frontend-landingpage/Dockerfile.server index e2d1d5288..26df2ea81 100644 --- a/apps/decabill/frontend-landingpage/Dockerfile.server +++ b/apps/decabill/frontend-landingpage/Dockerfile.server @@ -22,6 +22,9 @@ WORKDIR /app EXPOSE ${PORT} +# CVE-2026-59873: upgrade npm so nested tar is >= 7.5.19 +RUN npm install -g npm@11.18.0 && npm cache clean --force + COPY --chown=${APP_UID}:${APP_GID} . /app USER ${APP_UID}:${APP_GID} diff --git a/apps/forepath/backend-communication/Dockerfile.api b/apps/forepath/backend-communication/Dockerfile.api index 4392756df..6256b1d04 100644 --- a/apps/forepath/backend-communication/Dockerfile.api +++ b/apps/forepath/backend-communication/Dockerfile.api @@ -42,6 +42,8 @@ RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | P . "${NVM_DIR}/nvm.sh" && \ nvm install "${NODE_VERSION}" && \ nvm alias default "${NODE_VERSION}" && \ + npm install -g npm@11.18.0 && \ + npm cache clean --force && \ chown -R agenstra:agenstra /home/agenstra COPY --chown=agenstra:agenstra package*.json ./ diff --git a/apps/forepath/frontend-landingpage/Dockerfile b/apps/forepath/frontend-landingpage/Dockerfile index 08993823a..a1b8a8543 100644 --- a/apps/forepath/frontend-landingpage/Dockerfile +++ b/apps/forepath/frontend-landingpage/Dockerfile @@ -12,6 +12,9 @@ ENV PORT=4000 WORKDIR /app +# CVE-2026-59873: upgrade npm so nested tar is >= 7.5.19 +RUN npm install -g npm@11.18.0 && npm cache clean --force + COPY . /app diff --git a/apps/forepath/frontend-landingpage/Dockerfile.server b/apps/forepath/frontend-landingpage/Dockerfile.server index 46c8c4abd..655a94c9a 100644 --- a/apps/forepath/frontend-landingpage/Dockerfile.server +++ b/apps/forepath/frontend-landingpage/Dockerfile.server @@ -20,6 +20,9 @@ WORKDIR /app EXPOSE ${PORT} +# CVE-2026-59873: upgrade npm so nested tar is >= 7.5.19 +RUN npm install -g npm@11.18.0 && npm cache clean --force + COPY --chown=${APP_UID}:${APP_GID} . /app USER ${APP_UID}:${APP_GID} diff --git a/apps/shared/frontend-docs/Dockerfile.server b/apps/shared/frontend-docs/Dockerfile.server index 719a6b9df..d5553cae2 100644 --- a/apps/shared/frontend-docs/Dockerfile.server +++ b/apps/shared/frontend-docs/Dockerfile.server @@ -22,6 +22,9 @@ WORKDIR /app EXPOSE ${PORT} +# CVE-2026-59873: upgrade npm so nested tar is >= 7.5.19 +RUN npm install -g npm@11.18.0 && npm cache clean --force + COPY --chown=${APP_UID}:${APP_GID} . /app USER ${APP_UID}:${APP_GID} diff --git a/docs/decabill/features/README.md b/docs/decabill/features/README.md index 1722a8bdc..75e1fae8e 100644 --- a/docs/decabill/features/README.md +++ b/docs/decabill/features/README.md @@ -13,6 +13,7 @@ Decabill provides a complete set of capabilities for subscription billing, invoi - **Service Types and Plans** - Admin-managed catalog with provider schemas and pricing - **Billing Administration** - Manual invoices, customer profiles, KPIs, and bill-now - **Customer Profiles** - Self-service and admin billing metadata required for ordering +- **Customer Trust Score** - Admin-only traffic-light trust ranking on billing profiles - **Dashboard and Server Control** - Overview of subscriptions with start, stop, and restart actions - **Real-time Status** - WebSocket dashboard stream for provisioned server status - **Backorders** - Queue and retry when provider capacity is unavailable @@ -118,6 +119,10 @@ Billing metadata required before subscription orders and for compliant invoice i - Stripe customer ID stored on profile when payments are initiated - Completeness validation before `POST /subscriptions` +### [Customer Trust Score](./customer-trust-score.md) + +Admin-only trust ranking for billing profiles based on Decabill subscription, invoice, payment, auto-billing, withdrawal, and backorder history. + ### [Dashboard and Server Control](./dashboard-and-server-control.md) Customer overview of active subscriptions with live server status and power actions. @@ -260,6 +265,10 @@ graph TB CIC --> SUB CP --> SUB CP --> INV + CP --> BA + CP --> PRJ + CP --> CTS[Customer Trust Score] + BA --> CTS SUB --> SP SUB --> BO SUB --> DASH @@ -272,7 +281,6 @@ graph TB DP --> ST SUB --> INV BA --> PRJ - CP --> PRJ PRJ --> PB PRJ --> INV ``` diff --git a/docs/decabill/features/billing-administration.md b/docs/decabill/features/billing-administration.md index e2f951ccc..8b595f6f8 100644 --- a/docs/decabill/features/billing-administration.md +++ b/docs/decabill/features/billing-administration.md @@ -75,15 +75,18 @@ sequenceDiagram Customer billing data is stored in `billing_customer_profiles` (one profile per user). -| Method | Path | Purpose | -| ------ | --------------------------------------- | ------------------------------------------------------ | -| GET | `/admin/billing/customer-profiles` | Paginated list | -| GET | `/admin/billing/customer-profiles/{id}` | Full profile detail | -| POST | `/admin/billing/customer-profiles` | Create for user | -| POST | `/admin/billing/customer-profiles/{id}` | Update | -| DELETE | `/admin/billing/customer-profiles/{id}` | Delete (blocked if user has invoices or subscriptions) | +| Method | Path | Purpose | +| ------ | ------------------------------------------------------------- | ------------------------------------------------------ | +| GET | `/admin/billing/customer-profiles` | Paginated list | +| GET | `/admin/billing/customer-profiles/{id}` | Full profile detail | +| GET | `/admin/billing/customer-profiles/{id}/trust-score` | Recomputed trust score detail | +| POST | `/admin/billing/customer-profiles/{id}/trust-score/recompute` | Force trust recompute | +| POST | `/admin/billing/customer-profiles` | Create for user | +| POST | `/admin/billing/customer-profiles/{id}` | Update | +| DELETE | `/admin/billing/customer-profiles/{id}` | Delete (blocked if user has invoices or subscriptions) | Self-service `GET/POST /customer-profile` remains for end users. See [Customer Profiles](./customer-profiles.md). +Trust ranking remains admin-only. See [Customer Trust Score](./customer-trust-score.md). **Frontend:** `/administration/customer-profiles` in the billing console. @@ -108,6 +111,7 @@ See **[Projects](./projects.md)** for assignment rules, KPIs, and bill-time prec - **Billing dashboard** (`/administration/billing`) - KPIs, charts, bill-now - **Customer profiles** (`/administration/customer-profiles`) - Admin CRUD +- **Customer trust score** - Admin-only traffic-light ranking inside customer profiles - **Projects** (`/administration/projects`) - Project CRUD and bill-time - **Webhooks** (`/webhooks`) - Tenant-scoped outbound notification endpoints; see [Webhooks](./webhooks.md) - **Service types and plans** - Catalog administration in the billing console @@ -136,6 +140,7 @@ See **[Webhooks](./webhooks.md)** for payload envelope, signing, and event types - **[Invoices](./invoices.md)** - Status model and open positions - **[Customer Profiles](./customer-profiles.md)** - Profile fields and validation +- **[Customer Trust Score](./customer-trust-score.md)** - Trust ranking thresholds, factors, and webhooks - **[Projects](./projects.md)** - Admin project CRUD and bill-time - **[Multi-tenancy](./multi-tenancy.md)** - Tenant scope and DR-002 - **[Authentication](./authentication.md)** - Admin role requirements diff --git a/docs/decabill/features/customer-profiles.md b/docs/decabill/features/customer-profiles.md index d7f5b9da1..87e61c202 100644 --- a/docs/decabill/features/customer-profiles.md +++ b/docs/decabill/features/customer-profiles.md @@ -40,6 +40,7 @@ When the user initiates payment, the billing manager creates or updates a Stripe ## Admin Management Admins manage profiles under `/admin/billing/customer-profiles`. See [Billing Administration](./billing-administration.md). +The same admin surface also exposes the [Customer Trust Score](./customer-trust-score.md) for operator review. Rules: @@ -82,6 +83,7 @@ The user's registration date (day of month, capped at 28) defaults as their **bi - **[Invoices](./invoices.md)** - Issuer and customer data on PDFs - **[Projects](./projects.md)** - Profile required for project bill-time - **[Billing Administration](./billing-administration.md)** - Admin profile CRUD +- **[Customer Trust Score](./customer-trust-score.md)** - Admin-only trust ranking and factor breakdown - **[Payment Processing](./payment-processing.md)** - Stripe customer linkage - **[Billing Manager OpenAPI](/spec/billing-manager/openapi.yaml)** - Profile DTO schemas diff --git a/docs/decabill/features/customer-trust-score.md b/docs/decabill/features/customer-trust-score.md new file mode 100644 index 000000000..1c584fb48 --- /dev/null +++ b/docs/decabill/features/customer-trust-score.md @@ -0,0 +1,98 @@ +# Customer Trust Score + +Admins can review a per-profile trust score for billing customers in Decabill. The score is shown as a traffic light in the admin billing-profile list and exposes a factor breakdown in a modal on the profile detail surface. + +## Scope + +- **Audience:** Admins only +- **Frontend route:** `/administration/customer-profiles` +- **REST API:** `/admin/billing/customer-profiles/{id}/trust-score` +- **Data source:** Internal Decabill billing and profile data only + +Customer self-service routes do **not** expose trust score data. + +## How scoring works + +Each billing profile starts at a base score and then gains or loses points from trust factors derived from subscriptions, invoices, payment attempts, auto-billing readiness, withdrawals, and backorders. + +| Threshold | Level | +| --------- | ------ | +| `>= 120` | Green | +| `70-119` | Yellow | +| `< 70` | Red | + +| Constant | Value | +| ------------ | -------- | +| Base score | `100` | +| Score clamp | `0-200` | +| Snapshot TTL | `1 hour` | + +The source of truth for weights and caps is `trust-score.constants.ts` in the billing-manager feature library. + +## Implemented factors + +| Factor id | Points | Rule | +| ----------------------------- | ------------------- | ------------------------------------------------------------------- | +| `base_score` | `+100` | Neutral baseline for every customer | +| `profile_complete` | `+10` | Billing profile passes Decabill completeness checks | +| `active_or_past_subscription` | `+15` | Customer has at least one active or past subscription | +| `multi_period_tenure` | `+20` | Customer has at least two billed subscription invoices | +| `on_time_payments` | `+5` each, cap `5` | Successful payment attempts completed on or before invoice due date | +| `auto_billing_ready` | `+10` | Auto-billing enabled and reusable payment method on file | +| `no_withdrawal` | `+10` | Subscription history exists and no withdrawal is recorded | +| `overdue_invoices` | `-15` each, cap `5` | Customer currently has open or overdue invoices | +| `failed_payments` | `-10` each, cap `5` | Failed payment attempts are recorded | +| `auto_payment_exhausted` | `-20` | At least one invoice reached `auto_payment_status = exhausted` | +| `product_withdrawal` | `-25` | A subscription withdrawal was initiated or completed | +| `backorder_failures` | `-5` each, cap `3` | Failed backorder attempts are recorded | + +## Persistence model + +Decabill stores a denormalized trust snapshot on `billing_customer_profiles`: + +- `trust_score` +- `trust_level` +- `trust_score_updated_at` + +The admin list uses that snapshot so profile pagination stays cheap. The score detail endpoint recomputes the full factor breakdown and refreshes the stored snapshot. + +## Recompute triggers + +The trust score is refreshed automatically after these billing mutations: + +- invoice issue, manual paid/unpaid changes, and invoice voiding +- payment success and payment failure +- subscription create, cancel, resume, and withdrawal start +- auto-billing enable or disable +- payment method attachment + +Admins can also force a recompute from the trust-score detail modal or the dedicated recompute endpoint. + +## Admin API + +| Method | Path | Purpose | +| ------ | ------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `GET` | `/admin/billing/customer-profiles` | Returns `trustScore`, `trustLevel`, and `trustScoreUpdatedAt` in list items | +| `GET` | `/admin/billing/customer-profiles/{id}` | Returns stored trust snapshot on admin profile detail | +| `GET` | `/admin/billing/customer-profiles/{id}/trust-score` | Recomputes and returns score, level, and factor breakdown | +| `POST` | `/admin/billing/customer-profiles/{id}/trust-score/recompute` | Forces recompute and returns refreshed detail | + +## Webhooks + +Decabill publishes an admin-facing webhook event when the traffic-light level changes: + +- `customer_trust.level_changed` + +Payload fields: + +- `userId` +- `profileId` +- `previousLevel` +- `level` +- `score` + +No billing profile address data or other PII is included in the event. + +## Extensibility + +The scoring engine uses a provider registry. Today Decabill registers the built-in `internal_billing` provider, but additional providers can be added later for external credit systems, remote address validation, or other risk signals without replacing the admin API or snapshot model. diff --git a/docs/decabill/features/webhooks.md b/docs/decabill/features/webhooks.md index 2a1026f9a..525390c90 100644 --- a/docs/decabill/features/webhooks.md +++ b/docs/decabill/features/webhooks.md @@ -50,9 +50,11 @@ Events are published from the **billing** service after successful mutations. - `payment.auto.initiated`, `payment.auto.retry_scheduled`, `payment.auto.exhausted` - `auto_billing.enabled`, `auto_billing.disabled` - `payment_method.attached` +- `customer_trust.level_changed` - `subscription.created`, `subscription.updated`, `subscription.canceled` Payment success/failure payloads may include `mode` (`checkout` | `auto`). Auto-billing events are documented in [Auto-Billing](./auto-billing.md). +`customer_trust.level_changed` only includes identifiers plus level and score metadata; it never includes billing-profile address fields or other PII. ### Projects diff --git a/graph/graph.json b/graph/graph.json index 9062c25ad..558bf3d6c 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-07-19T12:33:17.315Z", + "generatedAt": "2026-07-21T21:11:29.136Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -5710,6 +5710,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/customer-trust-score.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/customer-trust-score.dto.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/invoice-detail-response.dto.ts", "type": "dto", @@ -7438,6 +7447,24 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/customer-trust-score.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/customer-trust-score.service.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/internal-billing-trust-score.provider.ts", + "type": "provider", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/internal-billing-trust-score.provider.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-controller.utils.ts", "type": "controller", @@ -10739,6 +10766,14 @@ "languageOrKind": "md" } }, + { + "id": "file:docs/decabill/features/customer-trust-score.md", + "type": "doc", + "attrs": { + "path": "docs/decabill/features/customer-trust-score.md", + "languageOrKind": "md" + } + }, { "id": "file:docs/decabill/features/dashboard-and-server-control.md", "type": "doc", @@ -14249,6 +14284,28 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/admin/billing/customer-profiles/{id}/trust-score", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/admin/billing/customer-profiles/{id}/trust-score", + "operationId": "getAdminCustomerProfileTrustScore", + "summary": "Get customer trust score detail (admin only)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/admin/billing/customer-profiles/{id}/trust-score/recompute", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/admin/billing/customer-profiles/{id}/trust-score/recompute", + "operationId": "recomputeAdminCustomerProfileTrustScore", + "summary": "Recompute customer trust score (admin only)", + "specKind": "openapi" + } + }, { "id": "api:HTTP:POST:/admin/billing/customer-profiles/{id}/auto-billing/setup", "type": "endpoint", @@ -14685,6 +14742,15 @@ "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" } }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:customer_trust.level_changed", + "type": "webhook-event", + "attrs": { + "eventName": "customer_trust.level_changed", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, { "id": "webhook-event:decabill-backend-feature-billing-manager:datev_export.completed", "type": "webhook-event", @@ -21808,6 +21874,86 @@ "domain": "decabill" } }, + { + "id": "concept:decabill-customer-trust-score", + "type": "concept", + "attrs": { + "title": "Customer Trust Score", + "docPath": "docs/decabill/features/customer-trust-score.md", + "sectionAnchor": "customer-trust-score", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-scope", + "type": "concept", + "attrs": { + "title": "Scope", + "docPath": "docs/decabill/features/customer-trust-score.md", + "sectionAnchor": "scope", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-how-scoring-works", + "type": "concept", + "attrs": { + "title": "How scoring works", + "docPath": "docs/decabill/features/customer-trust-score.md", + "sectionAnchor": "how-scoring-works", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-implemented-factors", + "type": "concept", + "attrs": { + "title": "Implemented factors", + "docPath": "docs/decabill/features/customer-trust-score.md", + "sectionAnchor": "implemented-factors", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-persistence-model", + "type": "concept", + "attrs": { + "title": "Persistence model", + "docPath": "docs/decabill/features/customer-trust-score.md", + "sectionAnchor": "persistence-model", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-recompute-triggers", + "type": "concept", + "attrs": { + "title": "Recompute triggers", + "docPath": "docs/decabill/features/customer-trust-score.md", + "sectionAnchor": "recompute-triggers", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-webhooks", + "type": "concept", + "attrs": { + "title": "Webhooks", + "docPath": "docs/decabill/features/customer-trust-score.md", + "sectionAnchor": "webhooks", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-extensibility", + "type": "concept", + "attrs": { + "title": "Extensibility", + "docPath": "docs/decabill/features/customer-trust-score.md", + "sectionAnchor": "extensibility", + "domain": "decabill" + } + }, { "id": "concept:decabill-dashboard-and-server-control", "type": "concept", @@ -28295,6 +28441,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/customer-profile.dto.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/customer-trust-score.dto.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/invoice-detail-response.dto.ts", @@ -29255,6 +29406,16 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/withdrawal-refund.service.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/customer-trust-score.service.ts", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/internal-billing-trust-score.provider.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-controller.utils.ts", @@ -32330,6 +32491,16 @@ "to": "api:HTTP:DELETE:/admin/billing/customer-profiles/{id}", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/admin/billing/customer-profiles/{id}/trust-score", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/admin/billing/customer-profiles/{id}/trust-score/recompute", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:POST:/admin/billing/customer-profiles/{id}/auto-billing/setup", @@ -32530,6 +32701,11 @@ "to": "webhook-event:decabill-backend-feature-billing-manager:auto_billing.enabled", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:customer_trust.level_changed", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "webhook-event:decabill-backend-feature-billing-manager:datev_export.completed", @@ -37270,6 +37446,51 @@ "to": "concept:decabill-related-documentation", "type": "contains" }, + { + "from": "file:docs/decabill/features/customer-trust-score.md", + "to": "concept:decabill-customer-trust-score", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-trust-score.md", + "to": "concept:decabill-scope", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-trust-score.md", + "to": "concept:decabill-how-scoring-works", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-trust-score.md", + "to": "concept:decabill-implemented-factors", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-trust-score.md", + "to": "concept:decabill-persistence-model", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-trust-score.md", + "to": "concept:decabill-recompute-triggers", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-trust-score.md", + "to": "concept:decabill-admin-api", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-trust-score.md", + "to": "concept:decabill-webhooks", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-trust-score.md", + "to": "concept:decabill-extensibility", + "type": "contains" + }, { "from": "file:docs/decabill/features/dashboard-and-server-control.md", "to": "concept:decabill-dashboard-and-server-control", @@ -40110,6 +40331,26 @@ "to": "api:HTTP:DELETE:/admin/billing/customer-profiles/{id}", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:GET:/admin/billing/customer-profiles/{id}/trust-score", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/admin/billing/customer-profiles/{id}/trust-score", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:POST:/admin/billing/customer-profiles/{id}/trust-score/recompute", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/admin/billing/customer-profiles/{id}/trust-score/recompute", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", "to": "api:HTTP:POST:/admin/billing/customer-profiles/{id}/auto-billing/setup", @@ -40435,6 +40676,16 @@ "to": "api:HTTP:DELETE:/admin/billing/customer-profiles/{id}", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts", + "to": "api:HTTP:GET:/admin/billing/customer-profiles/{id}/trust-score", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts", + "to": "api:HTTP:POST:/admin/billing/customer-profiles/{id}/trust-score/recompute", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts", "to": "api:HTTP:POST:/admin/billing/customer-profiles/{id}/auto-billing/setup", @@ -46220,6 +46471,16 @@ "to": "api:HTTP:DELETE:/admin/billing/customer-profiles/{id}", "type": "documents" }, + { + "from": "concept:decabill-customer-billing-profiles-admin", + "to": "api:HTTP:GET:/admin/billing/customer-profiles/{id}/trust-score", + "type": "documents" + }, + { + "from": "concept:decabill-customer-billing-profiles-admin", + "to": "api:HTTP:POST:/admin/billing/customer-profiles/{id}/trust-score/recompute", + "type": "documents" + }, { "from": "concept:decabill-admin-projects", "to": "api:HTTP:GET:/projects", @@ -46372,37 +46633,57 @@ }, { "from": "concept:decabill-admin-api", - "to": "api:HTTP:GET:/service-plans", + "to": "api:HTTP:GET:/cloud-init-configs", "type": "documents" }, { "from": "concept:decabill-admin-api", - "to": "api:HTTP:POST:/service-plans", + "to": "api:HTTP:POST:/cloud-init-configs", "type": "documents" }, { "from": "concept:decabill-admin-api", - "to": "api:HTTP:GET:/cloud-init-configs", + "to": "api:HTTP:GET:/customer-profile", "type": "documents" }, { "from": "concept:decabill-admin-api", - "to": "api:HTTP:POST:/cloud-init-configs", + "to": "api:HTTP:POST:/customer-profile", "type": "documents" }, { "from": "concept:decabill-admin-api", - "to": "api:HTTP:GET:/cloud-init-configs/{id}", + "to": "api:HTTP:GET:/admin/billing/customer-profiles", "type": "documents" }, { "from": "concept:decabill-admin-api", - "to": "api:HTTP:POST:/cloud-init-configs/{id}", + "to": "api:HTTP:POST:/admin/billing/customer-profiles", "type": "documents" }, { "from": "concept:decabill-admin-api", - "to": "api:HTTP:DELETE:/cloud-init-configs/{id}", + "to": "api:HTTP:GET:/admin/billing/customer-profiles/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-admin-api", + "to": "api:HTTP:POST:/admin/billing/customer-profiles/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-admin-api", + "to": "api:HTTP:DELETE:/admin/billing/customer-profiles/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-admin-api", + "to": "api:HTTP:GET:/admin/billing/customer-profiles/{id}/trust-score", + "type": "documents" + }, + { + "from": "concept:decabill-admin-api", + "to": "api:HTTP:POST:/admin/billing/customer-profiles/{id}/trust-score/recompute", "type": "documents" }, { @@ -46680,6 +46961,51 @@ "to": "api:HTTP:POST:/invoices", "type": "documents" }, + { + "from": "concept:decabill-scope", + "to": "api:HTTP:GET:/customer-profile", + "type": "documents" + }, + { + "from": "concept:decabill-scope", + "to": "api:HTTP:POST:/customer-profile", + "type": "documents" + }, + { + "from": "concept:decabill-scope", + "to": "api:HTTP:GET:/admin/billing/customer-profiles", + "type": "documents" + }, + { + "from": "concept:decabill-scope", + "to": "api:HTTP:POST:/admin/billing/customer-profiles", + "type": "documents" + }, + { + "from": "concept:decabill-scope", + "to": "api:HTTP:GET:/admin/billing/customer-profiles/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-scope", + "to": "api:HTTP:POST:/admin/billing/customer-profiles/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-scope", + "to": "api:HTTP:DELETE:/admin/billing/customer-profiles/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-scope", + "to": "api:HTTP:GET:/admin/billing/customer-profiles/{id}/trust-score", + "type": "documents" + }, + { + "from": "concept:decabill-extensibility", + "to": "api:HTTP:POST:/auth/register", + "type": "documents" + }, { "from": "concept:decabill-error-handling", "to": "api:HTTP:GET:/backorders", @@ -49505,6 +49831,11 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "file:docs/decabill/features/customer-trust-score.md", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "file:docs/decabill/features/dashboard-and-server-control.md", "to": "domain:decabill", @@ -52590,6 +52921,46 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "concept:decabill-customer-trust-score", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-scope", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-how-scoring-works", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-implemented-factors", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-persistence-model", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-recompute-triggers", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-webhooks", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-extensibility", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "concept:decabill-dashboard-and-server-control", "to": "domain:decabill", diff --git a/libs/domains/decabill/backend/feature-billing-manager/README.md b/libs/domains/decabill/backend/feature-billing-manager/README.md index fa965a489..b46bd7867 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/README.md +++ b/libs/domains/decabill/backend/feature-billing-manager/README.md @@ -20,6 +20,7 @@ Backend billing module providing subscription management, backorders, availabili - Customer profile management for invoicing metadata. - **Admin manual invoices:** Draft → edit → issue workflow via `/admin/billing/invoices/manual` (see `docs/agenstra/features/billing-administration.md`). - **Admin customer profiles:** CRUD via `/admin/billing/customer-profiles` (admin only). +- **Admin customer trust score:** Traffic-light ranking and factor detail under `/admin/billing/customer-profiles/{id}/trust-score`. - Usage-based pricing supported via usage records. ## Auth diff --git a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml index 6df63dfba..7dc1f6019 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml +++ b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml @@ -3133,6 +3133,40 @@ paths: responses: '204': description: Profile deleted + /admin/billing/customer-profiles/{id}/trust-score: + get: + tags: [Admin Billing] + summary: Get customer trust score detail (admin only) + operationId: getAdminCustomerProfileTrustScore + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Trust score detail + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerTrustScoreResponse' + /admin/billing/customer-profiles/{id}/trust-score/recompute: + post: + tags: [Admin Billing] + summary: Recompute customer trust score (admin only) + operationId: recomputeAdminCustomerProfileTrustScore + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Recomputed trust score detail + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerTrustScoreResponse' /admin/billing/customer-profiles/{id}/auto-billing/setup: post: tags: [Admin Billing] @@ -5407,6 +5441,9 @@ components: country: { type: string, nullable: true } isComplete: { type: boolean } stripeCustomerId: { type: string, nullable: true } + trustScore: { type: integer, nullable: true } + trustLevel: { type: string, enum: [green, yellow, red], nullable: true } + trustScoreUpdatedAt: { type: string, format: date-time, nullable: true } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } PaginatedAdminCustomerProfilesResponse: @@ -5433,6 +5470,39 @@ components: properties: userEmail: { type: string, format: email, nullable: true } isComplete: { type: boolean } + trustScore: { type: integer, nullable: true } + trustLevel: + { type: string, enum: [green, yellow, red], nullable: true } + trustScoreUpdatedAt: + { type: string, format: date-time, nullable: true } + CustomerTrustScoreFactor: + type: object + properties: + id: { type: string } + label: { type: string } + description: { type: string } + points: { type: integer } + source: { type: string } + metadata: + type: object + nullable: true + additionalProperties: true + CustomerTrustScoreResponse: + type: object + properties: + profileId: { type: string, format: uuid } + userId: { type: string, format: uuid } + score: { type: integer } + level: { type: string, enum: [green, yellow, red] } + baseScore: { type: integer } + factors: + type: array + items: + $ref: '#/components/schemas/CustomerTrustScoreFactor' + computedAt: { type: string, format: date-time } + sources: + type: array + items: { type: string } CloudInitConfigEnvVariableDefinition: type: object required: [key, label, showInOrderForm, hasDefault] diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/index.ts b/libs/domains/decabill/backend/feature-billing-manager/src/index.ts index a3df55c8c..212a0b400 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/index.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/index.ts @@ -42,6 +42,7 @@ export * from './lib/dto/create-subscription.dto'; export * from './lib/dto/create-usage-record.dto'; export * from './lib/dto/customer-profile-response.dto'; export * from './lib/dto/customer-profile.dto'; +export * from './lib/dto/customer-trust-score.dto'; export * from './lib/dto/invoice-detail-response.dto'; export * from './lib/dto/invoice-response.dto'; export * from './lib/dto/pricing-preview.dto'; @@ -187,6 +188,11 @@ export * from './lib/queue/datev-export.payload'; export * from './lib/payment-processors/payment-processor.factory'; export * from './lib/payment-processors/payment-processor.interface'; export * from './lib/payment-processors/processors/stripe-payment.processor'; +export * from './lib/trust-score/customer-trust-score.service'; +export * from './lib/trust-score/trust-score.constants'; +export * from './lib/trust-score/trust-score-provider.interface'; +export * from './lib/trust-score/trust-score-provider.registry'; +export * from './lib/trust-score/trust-score.types'; export * from './lib/utils/billing-day.utils'; export * from './lib/utils/config-validation.utils'; export * from './lib/utils/hostname-generator.utils'; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts index 073d5d463..700675b49 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts @@ -221,6 +221,9 @@ import { TaxRateConfigService } from './services/tax-rate-config.service'; import { UsageService } from './services/usage.service'; import { applyProviderConfigFieldScopes } from './utils/provider-config-schema.utils'; import { DIGITALOCEAN_ENV_DEFAULT_FIELDS, HETZNER_ENV_DEFAULT_FIELDS } from './utils/provider-env-defaults.utils'; +import { CustomerTrustScoreService } from './trust-score/customer-trust-score.service'; +import { InternalBillingTrustScoreProvider } from './trust-score/internal-billing-trust-score.provider'; +import { TrustScoreProviderRegistry } from './trust-score/trust-score-provider.registry'; const authMethod = getAuthenticationMethod(); /** @@ -561,6 +564,9 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { DynamicProviderLoaderService, PaymentOrchestrationService, AutoBillingService, + CustomerTrustScoreService, + TrustScoreProviderRegistry, + InternalBillingTrustScoreProvider, InvoiceAutoPaymentJobHandler, { provide: PAYMENT_PROCESSOR_INIT, @@ -698,6 +704,7 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { PublicWithdrawalService, UsageService, CustomerProfilesService, + CustomerTrustScoreService, SubscriptionBillingJobHandler, SubscriptionExpirationJobHandler, SubscriptionWithdrawalJobHandler, @@ -733,6 +740,8 @@ export class BillingModule implements OnModuleInit { constructor( private readonly providerRegistry: ProviderRegistryService, private readonly dynamicLoader: DynamicProviderLoaderService, + private readonly trustScoreProviderRegistry: TrustScoreProviderRegistry, + private readonly internalBillingTrustScoreProvider: InternalBillingTrustScoreProvider, ) {} async onModuleInit(): Promise { @@ -756,5 +765,7 @@ export class BillingModule implements OnModuleInit { dynamicLoader: this.dynamicLoader, loggerContext: 'ProviderRegistryService', }); + + this.trustScoreProviderRegistry.register(this.internalBillingTrustScoreProvider); } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts index 91681f0e5..2eae965a8 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts @@ -14,11 +14,13 @@ import { } from '@nestjs/common'; import type { + AdminCustomerProfileDetailDto, CreateAdminCustomerProfileDto, PaginatedAdminCustomerProfilesResponseDto, } from '../dto/admin-customer-profile.dto'; import { CustomerProfileDto } from '../dto/customer-profile.dto'; import type { CustomerProfileResponseDto } from '../dto/customer-profile-response.dto'; +import type { CustomerTrustScoreResponseDto } from '../dto/customer-trust-score.dto'; import { CustomerProfilesAdminService } from '../services/customer-profiles-admin.service'; @Controller('admin/billing/customer-profiles') @@ -37,10 +39,17 @@ export class AdminCustomerProfilesController { } @Get(':id') - async get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + async get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { return await this.customerProfilesAdminService.getById(id); } + @Get(':id/trust-score') + async getTrustScore( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + ): Promise { + return await this.customerProfilesAdminService.getTrustScore(id); + } + @Post() async create(@Body() dto: CreateAdminCustomerProfileDto): Promise { return await this.customerProfilesAdminService.create(dto); @@ -54,6 +63,13 @@ export class AdminCustomerProfilesController { return await this.customerProfilesAdminService.update(id, dto); } + @Post(':id/trust-score/recompute') + async recomputeTrustScore( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + ): Promise { + return await this.customerProfilesAdminService.recomputeTrustScore(id); + } + @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async delete(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-customer-profile.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-customer-profile.dto.ts index 73d48757f..c05e8727b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-customer-profile.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-customer-profile.dto.ts @@ -1,5 +1,7 @@ import { IsEmail, IsOptional, IsString, IsUUID, Length } from 'class-validator'; +import type { CustomerTrustLevel } from '../trust-score/trust-score.types'; + import { CustomerProfileDto } from './customer-profile.dto'; export class CreateAdminCustomerProfileDto extends CustomerProfileDto { @@ -18,6 +20,25 @@ export class AdminCustomerProfileListItemDto { country?: string; isComplete!: boolean; stripeCustomerId?: string; + trustScore?: number | null; + trustLevel?: CustomerTrustLevel | null; + trustScoreUpdatedAt?: Date | null; + createdAt!: Date; + updatedAt!: Date; +} + +export class AdminCustomerProfileDetailDto extends CustomerProfileDto { + id!: string; + userId!: string; + userEmail?: string; + isComplete!: boolean; + stripeCustomerId?: string; + autoBillingEnabled?: boolean; + hasPaymentMethodOnFile?: boolean; + supportsAutoPayment?: boolean; + trustScore?: number | null; + trustLevel?: CustomerTrustLevel | null; + trustScoreUpdatedAt?: Date | null; createdAt!: Date; updatedAt!: Date; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/customer-trust-score.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/customer-trust-score.dto.ts new file mode 100644 index 000000000..d60962cc2 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/customer-trust-score.dto.ts @@ -0,0 +1,21 @@ +import type { CustomerTrustLevel } from '../trust-score/trust-score.types'; + +export class CustomerTrustScoreFactorDto { + id!: string; + label!: string; + description!: string; + points!: number; + source!: string; + metadata?: Record; +} + +export class CustomerTrustScoreResponseDto { + profileId!: string; + userId!: string; + score!: number; + level!: CustomerTrustLevel; + baseScore!: number; + factors!: CustomerTrustScoreFactorDto[]; + computedAt!: Date; + sources!: string[]; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/customer-profile.entity.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/customer-profile.entity.ts index 227b5cf68..b188bdd6f 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/customer-profile.entity.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/customer-profile.entity.ts @@ -1,5 +1,7 @@ import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { CustomerTrustLevel } from '../trust-score/trust-score.types'; + @Entity('billing_customer_profiles') export class CustomerProfileEntity { @PrimaryGeneratedColumn('uuid', { name: 'id' }) @@ -50,6 +52,15 @@ export class CustomerProfileEntity { @Column({ type: 'varchar', length: 255, nullable: true, name: 'default_payment_method_external_id' }) defaultPaymentMethodExternalId?: string; + @Column({ type: 'integer', nullable: true, name: 'trust_score' }) + trustScore?: number | null; + + @Column({ type: 'varchar', length: 16, nullable: true, name: 'trust_level' }) + trustLevel?: CustomerTrustLevel | null; + + @Column({ type: 'timestamp', nullable: true, name: 'trust_score_updated_at' }) + trustScoreUpdatedAt?: Date | null; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts index 658e62fc6..a48ea3718 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts @@ -30,6 +30,7 @@ describe('BILLING_NOTIFICATION_EVENTS', () => { 'auto_billing.enabled', 'auto_billing.disabled', 'payment_method.attached', + 'customer_trust.level_changed', 'payment.auto.initiated', 'payment.auto.retry_scheduled', 'payment.auto.exhausted', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts index 93b2847ca..4a61949ee 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts @@ -14,6 +14,7 @@ export const BILLING_NOTIFICATION_EVENTS = [ 'auto_billing.enabled', 'auto_billing.disabled', 'payment_method.attached', + 'customer_trust.level_changed', 'subscription.created', 'subscription.updated', 'subscription.canceled', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/backorders.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/backorders.repository.ts index ccb8413f4..cd4577819 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/backorders.repository.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/backorders.repository.ts @@ -36,6 +36,18 @@ export class BackordersRepository { }); } + async countFailedByUserId(userId: string): Promise { + const qb = this.repository + .createQueryBuilder('backorder') + .innerJoin('users', 'user', 'user.id = backorder.user_id') + .where('backorder.user_id = :userId', { userId }) + .andWhere('backorder.status = :status', { status: BackorderStatus.FAILED }); + + applyUserTenantFilter(qb, 'user'); + + return await qb.getCount(); + } + async findAllPending(limit = 100, offset = 0): Promise { const qb = this.repository .createQueryBuilder('backorder') diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.ts index c6d0b5376..130470413 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.ts @@ -473,6 +473,31 @@ export class InvoicesRepository { return await qb.getCount(); } + async countBilledSubscriptionInvoicesByUserId(userId: string): Promise { + const qb = this.repository + .createQueryBuilder('inv') + .innerJoin('users', 'user', 'user.id = inv.user_id') + .where('inv.user_id = :userId', { userId }) + .andWhere('inv.subscription_id IS NOT NULL') + .andWhere('inv.status IN (:...statuses)', { statuses: BILLED_INVOICE_STATUSES }); + + applyUserTenantFilter(qb, 'user'); + + return await qb.getCount(); + } + + async hasAutoPaymentExhaustedByUserId(userId: string): Promise { + const qb = this.repository + .createQueryBuilder('inv') + .innerJoin('users', 'user', 'user.id = inv.user_id') + .where('inv.user_id = :userId', { userId }) + .andWhere('inv.auto_payment_status = :status', { status: AutoPaymentStatus.EXHAUSTED }); + + applyUserTenantFilter(qb, 'user'); + + return (await qb.getCount()) > 0; + } + async delete(id: string): Promise { await this.findByIdOrThrow(id); await this.repository.delete(id); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/payment-attempts.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/payment-attempts.repository.ts index 2f6eb5bee..b2cd1f33f 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/payment-attempts.repository.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/payment-attempts.repository.ts @@ -81,6 +81,34 @@ export class PaymentAttemptsRepository { return await qb.getOne(); } + async countFailedByUserId(userId: string): Promise { + const qb = this.repository + .createQueryBuilder('attempt') + .innerJoin('attempt.invoice', 'inv') + .innerJoin('users', 'user', 'user.id = inv.user_id') + .where('inv.user_id = :userId', { userId }) + .andWhere('attempt.status = :status', { status: PaymentAttemptStatus.FAILED }); + + applyUserTenantFilter(qb, 'user'); + + return await qb.getCount(); + } + + async countSucceededOnTimeByUserId(userId: string): Promise { + const qb = this.repository + .createQueryBuilder('attempt') + .innerJoin('attempt.invoice', 'inv') + .innerJoin('users', 'user', 'user.id = inv.user_id') + .where('inv.user_id = :userId', { userId }) + .andWhere('attempt.status = :status', { status: PaymentAttemptStatus.SUCCEEDED }) + .andWhere('inv.due_date IS NOT NULL') + .andWhere(`attempt.updated_at < (inv.due_date::timestamp + INTERVAL '1 day')`); + + applyUserTenantFilter(qb, 'user'); + + return await qb.getCount(); + } + private async findByIdInTenant(id: string): Promise { const entity = await this.repository .createQueryBuilder('attempt') diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts index e9f372f03..097f55db1 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts @@ -85,6 +85,18 @@ export class SubscriptionsRepository { }); } + async findAllForUserInTenant(userId: string): Promise { + const qb = this.repository + .createQueryBuilder('subscription') + .innerJoin('users', 'user', 'user.id = subscription.user_id') + .where('subscription.user_id = :userId', { userId }) + .orderBy('subscription.createdAt', 'DESC'); + + applyUserTenantFilter(qb, 'user'); + + return await qb.getMany(); + } + async findAllForAdmin(params: AdminSubscriptionListParams): Promise<{ items: SubscriptionEntity[]; total: number }> { const qb = this.repository .createQueryBuilder('subscription') diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.spec.ts index de9cc1044..701a06586 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.spec.ts @@ -33,6 +33,9 @@ describe('AutoBillingService', () => { const billingEmailPublisher = { publishPaymentSucceeded: jest.fn(), }; + const customerTrustScoreService = { + triggerRecomputeForUser: jest.fn(), + }; const customerProfilesService = { isProfileComplete: jest.fn().mockReturnValue(true), }; @@ -81,6 +84,7 @@ describe('AutoBillingService', () => { auditLog as never, billingNotificationPublisher as never, billingEmailPublisher as never, + customerTrustScoreService as never, ); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.ts index 3dac58f7e..359ccf491 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.ts @@ -22,6 +22,7 @@ import { resolveTenantFrontendBaseUrl } from '../utils/tenant-frontend-url.utils import { BillingAuditLogService } from './billing-audit-log.service'; import { CustomerProfilesService } from './customer-profiles.service'; import { BillingEmailPublisher } from '../email/billing-email.publisher'; +import { CustomerTrustScoreService } from '../trust-score/customer-trust-score.service'; export interface AutoBillingSetupResult { setupUrl: string; @@ -41,6 +42,7 @@ export class AutoBillingService { private readonly auditLog: BillingAuditLogService, private readonly billingNotificationPublisher: BillingNotificationPublisher, private readonly billingEmailPublisher: BillingEmailPublisher, + private readonly customerTrustScoreService: CustomerTrustScoreService, ) {} get batchSizeLimit(): number { @@ -126,6 +128,7 @@ export class AutoBillingService { this.billingNotificationPublisher.publish('auto_billing.enabled', { userId, profileId: updated.id }, userId); await this.rescheduleOpenInvoicesForUser(userId); + this.customerTrustScoreService.triggerRecomputeForUser(userId); return updated; } @@ -146,6 +149,7 @@ export class AutoBillingService { }); this.billingNotificationPublisher.publish('auto_billing.disabled', { userId, profileId: updated.id }, userId); + this.customerTrustScoreService.triggerRecomputeForUser(userId); return updated; } @@ -174,6 +178,7 @@ export class AutoBillingService { }, params.userId, ); + this.customerTrustScoreService.triggerRecomputeForUser(params.userId); } async scheduleIfEligible(invoice: InvoiceEntity): Promise { @@ -463,6 +468,7 @@ export class AutoBillingService { attempt: attemptNumber, willRetry, }); + this.customerTrustScoreService.triggerRecomputeForUser(invoice.userId); if (willRetry && nextRetryAt) { this.billingNotificationPublisher.publish( @@ -548,6 +554,7 @@ export class AutoBillingService { externalId, mode: 'auto', }); + this.customerTrustScoreService.triggerRecomputeForUser(invoice.userId); await this.billingEmailPublisher.publishPaymentSucceeded(paid, { processor: processorType, externalId, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/customer-profiles-admin.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/customer-profiles-admin.service.spec.ts index dd8ff54d3..b8fe384a2 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/customer-profiles-admin.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/customer-profiles-admin.service.spec.ts @@ -15,6 +15,11 @@ describe('CustomerProfilesAdminService', () => { const usersRepository = { findByIdForTenant: jest.fn() }; const invoicesRepository = { countByUserId: jest.fn() }; const subscriptionsRepository = { findAllByUser: jest.fn() }; + const customerTrustScoreService = { + ensureFreshSnapshot: jest.fn(), + getSummaryForProfileId: jest.fn(), + recomputeForProfileId: jest.fn(), + }; const service = new CustomerProfilesAdminService( customerProfilesRepository as never, @@ -22,6 +27,7 @@ describe('CustomerProfilesAdminService', () => { usersRepository as never, invoicesRepository as never, subscriptionsRepository as never, + customerTrustScoreService as never, ); const profile = { @@ -38,6 +44,7 @@ describe('CustomerProfilesAdminService', () => { jest.resetAllMocks(); usersRepository.findByIdForTenant.mockResolvedValue({ id: 'user-1', email: 'user@example.com' }); customerProfilesService.isProfileComplete.mockReturnValue(true); + customerTrustScoreService.ensureFreshSnapshot.mockImplementation(async (value: unknown) => value); }); it('create rejects duplicate profile for user', async () => { @@ -129,4 +136,40 @@ describe('CustomerProfilesAdminService', () => { expect(result.country).toBe('DE'); }); + + it('getTrustScore returns recomputed trust detail', async () => { + customerProfilesRepository.findByIdOrThrow.mockResolvedValue(profile); + customerTrustScoreService.getSummaryForProfileId.mockResolvedValue({ + score: 125, + level: 'green', + baseScore: 100, + factors: [], + computedAt: new Date(), + sources: ['internal_billing'], + }); + + const result = await service.getTrustScore('profile-1'); + + expect(result.profileId).toBe('profile-1'); + expect(result.score).toBe(125); + expect(customerTrustScoreService.getSummaryForProfileId).toHaveBeenCalledWith('profile-1'); + }); + + it('recomputeTrustScore forces a fresh trust snapshot', async () => { + customerProfilesRepository.findByIdOrThrow.mockResolvedValue(profile); + customerTrustScoreService.recomputeForProfileId.mockResolvedValue({ + score: 118, + level: 'yellow', + baseScore: 100, + factors: [], + computedAt: new Date(), + sources: ['internal_billing'], + }); + + const result = await service.recomputeTrustScore('profile-1'); + + expect(result.profileId).toBe('profile-1'); + expect(result.score).toBe(118); + expect(customerTrustScoreService.recomputeForProfileId).toHaveBeenCalledWith('profile-1'); + }); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/customer-profiles-admin.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/customer-profiles-admin.service.ts index dff8bead7..9406f8006 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/customer-profiles-admin.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/customer-profiles-admin.service.ts @@ -2,16 +2,19 @@ import { UsersRepository } from '@forepath/identity/backend'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import type { + AdminCustomerProfileDetailDto, AdminCustomerProfileListItemDto, CreateAdminCustomerProfileDto, PaginatedAdminCustomerProfilesResponseDto, } from '../dto/admin-customer-profile.dto'; import type { CustomerProfileDto } from '../dto/customer-profile.dto'; import type { CustomerProfileResponseDto } from '../dto/customer-profile-response.dto'; +import type { CustomerTrustScoreResponseDto } from '../dto/customer-trust-score.dto'; import type { CustomerProfileEntity } from '../entities/customer-profile.entity'; import { CustomerProfilesRepository } from '../repositories/customer-profiles.repository'; import { InvoicesRepository } from '../repositories/invoices.repository'; import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; +import { CustomerTrustScoreService } from '../trust-score/customer-trust-score.service'; import { CustomerProfilesService } from './customer-profiles.service'; @@ -23,10 +26,14 @@ export class CustomerProfilesAdminService { private readonly usersRepository: UsersRepository, private readonly invoicesRepository: InvoicesRepository, private readonly subscriptionsRepository: SubscriptionsRepository, + private readonly customerTrustScoreService: CustomerTrustScoreService, ) {} async list(limit: number, offset: number): Promise { const { items, total } = await this.customerProfilesRepository.findAll(limit, offset); + const profiles = await Promise.all( + items.map((profile) => this.customerTrustScoreService.ensureFreshSnapshot(profile)), + ); const userIds = [...new Set(items.map((item) => item.userId))]; const userEmailById = new Map(); @@ -41,21 +48,58 @@ export class CustomerProfilesAdminService { ); return { - items: items.map((profile) => this.mapListItem(profile, userEmailById.get(profile.userId))), + items: profiles.map((profile) => this.mapListItem(profile, userEmailById.get(profile.userId))), total, limit, offset, }; } - async getById(id: string): Promise { - const profile = await this.customerProfilesRepository.findByIdOrThrow(id); + async getById(id: string): Promise { + const profile = await this.customerTrustScoreService.ensureFreshSnapshot( + await this.customerProfilesRepository.findByIdOrThrow(id), + ); const user = await this.usersRepository.findByIdForTenant(profile.userId); return { ...this.mapResponse(profile), userEmail: user?.email, isComplete: this.customerProfilesService.isProfileComplete(profile), + trustScore: profile.trustScore, + trustLevel: profile.trustLevel, + trustScoreUpdatedAt: profile.trustScoreUpdatedAt, + }; + } + + async getTrustScore(id: string): Promise { + const profile = await this.customerProfilesRepository.findByIdOrThrow(id); + const summary = await this.customerTrustScoreService.getSummaryForProfileId(id); + + return { + profileId: profile.id, + userId: profile.userId, + score: summary.score, + level: summary.level, + baseScore: summary.baseScore, + factors: summary.factors, + computedAt: summary.computedAt, + sources: summary.sources, + }; + } + + async recomputeTrustScore(id: string): Promise { + const profile = await this.customerProfilesRepository.findByIdOrThrow(id); + const summary = await this.customerTrustScoreService.recomputeForProfileId(id); + + return { + profileId: profile.id, + userId: profile.userId, + score: summary.score, + level: summary.level, + baseScore: summary.baseScore, + factors: summary.factors, + computedAt: summary.computedAt, + sources: summary.sources, }; } @@ -123,6 +167,9 @@ export class CustomerProfilesAdminService { country: profile.country, isComplete: this.customerProfilesService.isProfileComplete(profile), stripeCustomerId: profile.stripeCustomerId, + trustScore: profile.trustScore, + trustLevel: profile.trustLevel, + trustScoreUpdatedAt: profile.trustScoreUpdatedAt, createdAt: profile.createdAt, updatedAt: profile.updatedAt, }; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-admin.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-admin.service.spec.ts index 53718a65e..86ac19156 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-admin.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-admin.service.spec.ts @@ -13,11 +13,13 @@ describe('InvoiceAdminService', () => { const invoiceService = { mapToResponse: jest.fn(), voidInvoice: jest.fn() }; const auditLog = { log: jest.fn() }; const usersRepository = { findByIdForTenant: jest.fn() }; + const customerTrustScoreService = { triggerRecomputeForUser: jest.fn() }; const service = new InvoiceAdminService( invoicesRepository as never, invoiceService as never, auditLog as never, usersRepository as never, + customerTrustScoreService as never, ); const baseInvoice = { id: 'inv-1', @@ -59,6 +61,7 @@ describe('InvoiceAdminService', () => { context: expect.objectContaining({ adminUserId: 'admin-1' }), }), ); + expect(customerTrustScoreService.triggerRecomputeForUser).toHaveBeenCalledWith('user-1'); }); it('markPaidManual rejects invalid status', async () => { @@ -82,6 +85,7 @@ describe('InvoiceAdminService', () => { status: InvoiceStatus.ISSUED, balanceDue: 50, }); + expect(customerTrustScoreService.triggerRecomputeForUser).toHaveBeenCalledWith('user-1'); }); it('voidInvoice delegates to invoice service', async () => { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-admin.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-admin.service.ts index 151bc4b4b..975258d39 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-admin.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-admin.service.ts @@ -9,6 +9,7 @@ import type { } from '../dto/admin-billing.dto'; import type { InvoiceEntity } from '../entities/invoice.entity'; import { InvoicesRepository } from '../repositories/invoices.repository'; +import { CustomerTrustScoreService } from '../trust-score/customer-trust-score.service'; import { BillingAuditLogService } from './billing-audit-log.service'; import { InvoiceService } from './invoice.service'; @@ -22,6 +23,7 @@ export class InvoiceAdminService { private readonly invoiceService: InvoiceService, private readonly auditLog: BillingAuditLogService, private readonly usersRepository: UsersRepository, + private readonly customerTrustScoreService: CustomerTrustScoreService, ) {} async listInvoices(params: { @@ -97,6 +99,8 @@ export class InvoiceAdminService { }, }); + this.customerTrustScoreService.triggerRecomputeForUser(invoice.userId); + return this.mapAdminItem(updated); } @@ -140,6 +144,8 @@ export class InvoiceAdminService { }, }); + this.customerTrustScoreService.triggerRecomputeForUser(invoice.userId); + return this.mapAdminItem(updated); } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-issuance.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-issuance.service.spec.ts index 1d525f163..68e8478d3 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-issuance.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-issuance.service.spec.ts @@ -44,6 +44,9 @@ describe('InvoiceIssuanceService', () => { const auditLog = { log: jest.fn(), }; + const customerTrustScoreService = { + triggerRecomputeForUser: jest.fn(), + }; const service = new InvoiceIssuanceService( invoicesRepository as never, invoiceLineItemsRepository as never, @@ -64,6 +67,7 @@ describe('InvoiceIssuanceService', () => { { scheduleIfEligible: jest.fn(), } as never, + customerTrustScoreService as never, ); const draftInvoice = { id: 'inv-1', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-issuance.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-issuance.service.ts index e6bb2aefa..1729452d6 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-issuance.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-issuance.service.ts @@ -17,6 +17,7 @@ import { BillingEmailPublisher } from '../email/billing-email.publisher'; import { InvoicePdfService } from './invoice-pdf.service'; import { resolveInvoicingPeriod } from './invoicing-period.util'; import { resolvePurchaseOrderReference } from './purchase-order-reference.util'; +import { CustomerTrustScoreService } from '../trust-score/customer-trust-score.service'; export interface IssueDraftOptions { skipNotification?: boolean; @@ -37,6 +38,7 @@ export class InvoiceIssuanceService { private readonly auditLog: BillingAuditLogService, private readonly billingNotificationPublisher: BillingNotificationPublisher, private readonly autoBillingService: AutoBillingService, + private readonly customerTrustScoreService: CustomerTrustScoreService, ) {} async issueDraft(invoiceId: string, dueInDays = 14, options?: IssueDraftOptions): Promise { @@ -118,6 +120,8 @@ export class InvoiceIssuanceService { await this.autoBillingService.scheduleIfEligible(issued); } + this.customerTrustScoreService.triggerRecomputeForUser(invoice.userId); + return issued; } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.spec.ts index 017734143..ec2d1ea1a 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.spec.ts @@ -76,6 +76,9 @@ describe('InvoiceService', () => { publishSubscription: jest.fn(), publish: jest.fn(), }; + const customerTrustScoreService = { + triggerRecomputeForUser: jest.fn(), + }; const service = new InvoiceService( invoicesRepository as never, invoiceLineItemsRepository as never, @@ -93,6 +96,7 @@ describe('InvoiceService', () => { invoicePromotionApplicationsRepository as never, promotionApplicationService as never, billingNotificationPublisher as never, + customerTrustScoreService as never, ); const subscriptionId = 'sub-1'; const userId = 'user-1'; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.ts index 6c31f168b..6b8bb4a89 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice.service.ts @@ -29,6 +29,7 @@ import { resolveInvoicingPeriod } from './invoicing-period.util'; import { resolvePurchaseOrderReference } from './purchase-order-reference.util'; import type { LineItemInput } from './tax-calculation.service'; import { TaxCalculationService } from './tax-calculation.service'; +import { CustomerTrustScoreService } from '../trust-score/customer-trust-score.service'; export interface CreateInvoiceDraftParams { subscriptionId?: string; @@ -58,6 +59,7 @@ export class InvoiceService { private readonly invoicePromotionApplicationsRepository: InvoicePromotionApplicationsRepository, private readonly promotionApplicationService: PromotionApplicationService, private readonly billingNotificationPublisher: BillingNotificationPublisher, + private readonly customerTrustScoreService: CustomerTrustScoreService, ) {} async createAndIssue(params: CreateInvoiceDraftParams): Promise<{ invoiceRefId: string; invoiceNumber?: string }> { @@ -186,6 +188,7 @@ export class InvoiceService { }); this.billingNotificationPublisher.publishInvoice('invoice.voided', voided); + this.customerTrustScoreService.triggerRecomputeForUser(invoice.userId); return voided; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/payment-orchestration.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/payment-orchestration.service.spec.ts index 0f9d8b3e7..2819b127e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/payment-orchestration.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/payment-orchestration.service.spec.ts @@ -52,6 +52,9 @@ describe('PaymentOrchestrationService', () => { onAutoPaymentFailed: jest.Mock; onCheckoutPaymentMethodCaptured: jest.Mock; }; + const customerTrustScoreService = { + triggerRecomputeForUser: jest.fn(), + }; beforeEach(() => { jest.resetAllMocks(); @@ -88,6 +91,7 @@ describe('PaymentOrchestrationService', () => { billingNotificationPublisher as never, billingEmailPublisher as never, autoBillingService as never, + customerTrustScoreService as never, ); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/payment-orchestration.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/payment-orchestration.service.ts index b99bfaa97..9aa81698a 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/payment-orchestration.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/payment-orchestration.service.ts @@ -19,6 +19,7 @@ import { AutoBillingService } from './auto-billing.service'; import { BillingAuditLogService } from './billing-audit-log.service'; import { BillingNotificationPublisher } from '../notifications/billing-notification.publisher'; import { BillingEmailPublisher } from '../email/billing-email.publisher'; +import { CustomerTrustScoreService } from '../trust-score/customer-trust-score.service'; @Injectable() export class PaymentOrchestrationService { @@ -34,6 +35,7 @@ export class PaymentOrchestrationService { private readonly billingNotificationPublisher: BillingNotificationPublisher, private readonly billingEmailPublisher: BillingEmailPublisher, private readonly autoBillingService: AutoBillingService, + private readonly customerTrustScoreService: CustomerTrustScoreService, ) {} async initiatePayment(invoiceId: string, subscriptionId: string, userId: string): Promise<{ checkoutUrl: string }> { @@ -290,6 +292,7 @@ export class PaymentOrchestrationService { processor: processorType, externalId: update.externalId, }); + this.customerTrustScoreService.triggerRecomputeForUser(invoice.userId); return; } @@ -326,6 +329,7 @@ export class PaymentOrchestrationService { processor: processorType, externalId: update.externalId, }); + this.customerTrustScoreService.triggerRecomputeForUser(invoice.userId); } } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts index e3959b510..3586896a3 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts @@ -134,6 +134,9 @@ describe('SubscriptionService', () => { publishPaymentFailed: jest.fn(), publishSubscriptionCanceled: jest.fn(), }; + const customerTrustScoreService = { + triggerRecomputeForUser: jest.fn(), + }; const service = new SubscriptionService( plansRepository, typesRepository, @@ -157,6 +160,7 @@ describe('SubscriptionService', () => { promotionRedemptionService as never, billingNotificationPublisher as never, billingEmailPublisher as never, + customerTrustScoreService as never, ); beforeEach(() => { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts index 90ef7986f..96082a363 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts @@ -53,6 +53,7 @@ import { PromotionRedemptionService } from './promotion-redemption.service'; import { TaxCalculationService } from './tax-calculation.service'; import { BillingNotificationPublisher } from '../notifications/billing-notification.publisher'; import { BillingEmailPublisher } from '../email/billing-email.publisher'; +import { CustomerTrustScoreService } from '../trust-score/customer-trust-score.service'; @Injectable() export class SubscriptionService { @@ -81,6 +82,7 @@ export class SubscriptionService { private readonly promotionRedemptionService: PromotionRedemptionService, private readonly billingNotificationPublisher: BillingNotificationPublisher, private readonly billingEmailPublisher: BillingEmailPublisher, + private readonly customerTrustScoreService: CustomerTrustScoreService, ) {} async createSubscription( @@ -266,6 +268,7 @@ export class SubscriptionService { const created = await this.subscriptionsRepository.findByIdOrThrow(subscription.id); this.billingNotificationPublisher.publishSubscription('subscription.created', created); + this.customerTrustScoreService.triggerRecomputeForUser(created.userId); return created; } @@ -467,6 +470,7 @@ export class SubscriptionService { }); this.billingNotificationPublisher.publishSubscription('subscription.canceled', canceled); + this.customerTrustScoreService.triggerRecomputeForUser(canceled.userId); await this.billingEmailPublisher.publishSubscriptionCanceled(canceled, plan.name); return canceled; @@ -487,6 +491,7 @@ export class SubscriptionService { }); this.billingNotificationPublisher.publishSubscription('subscription.updated', resumed); + this.customerTrustScoreService.triggerRecomputeForUser(resumed.userId); return resumed; } @@ -539,6 +544,7 @@ export class SubscriptionService { const updated = await this.subscriptionsRepository.findByIdOrThrow(subscriptionId); this.billingNotificationPublisher.publishSubscription('subscription.updated', updated); + this.customerTrustScoreService.triggerRecomputeForUser(updated.userId); const withdrawalResult: WithdrawalResultDto = { refundGross: estimatedRefundGross, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/customer-trust-score.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/customer-trust-score.service.spec.ts new file mode 100644 index 000000000..844ce2d2b --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/customer-trust-score.service.spec.ts @@ -0,0 +1,152 @@ +import { CustomerTrustScoreService } from './customer-trust-score.service'; +import { TrustScoreProviderRegistry } from './trust-score-provider.registry'; +import { CustomerTrustLevel } from './trust-score.types'; + +describe('CustomerTrustScoreService', () => { + const customerProfilesRepository = { + findByIdOrThrow: jest.fn(), + findByUserId: jest.fn(), + update: jest.fn(), + }; + const billingNotificationPublisher = { + publish: jest.fn(), + }; + let registry: TrustScoreProviderRegistry; + let service: CustomerTrustScoreService; + + beforeEach(() => { + jest.resetAllMocks(); + registry = new TrustScoreProviderRegistry(); + service = new CustomerTrustScoreService( + customerProfilesRepository as never, + registry, + billingNotificationPublisher as never, + ); + }); + + it('recomputes and persists a trust snapshot', async () => { + registry.register({ + id: 'provider-a', + evaluate: jest.fn().mockResolvedValue([ + { + id: 'profile_complete', + label: 'Complete profile', + description: 'desc', + points: 10, + source: 'provider-a', + }, + { + id: 'failed_payments', + label: 'Failed payments', + description: 'desc', + points: -20, + source: 'provider-a', + }, + ]), + }); + customerProfilesRepository.findByIdOrThrow.mockResolvedValue({ + id: 'profile-1', + userId: 'user-1', + trustLevel: CustomerTrustLevel.YELLOW, + }); + customerProfilesRepository.update.mockImplementation(async (_id: string, dto: Record) => ({ + id: 'profile-1', + userId: 'user-1', + ...dto, + })); + + const result = await service.recomputeForProfileId('profile-1'); + + expect(result.score).toBe(90); + expect(result.level).toBe(CustomerTrustLevel.YELLOW); + expect(result.factors[0]).toEqual(expect.objectContaining({ id: 'base_score', points: 100 })); + expect(customerProfilesRepository.update).toHaveBeenCalledWith( + 'profile-1', + expect.objectContaining({ + trustScore: 90, + trustLevel: CustomerTrustLevel.YELLOW, + trustScoreUpdatedAt: expect.any(Date), + }), + ); + expect(billingNotificationPublisher.publish).not.toHaveBeenCalled(); + }); + + it('returns detail from a fresh snapshot without persisting again', async () => { + const computedAt = new Date(); + + registry.register({ + id: 'provider-a', + evaluate: jest.fn().mockResolvedValue([ + { + id: 'profile_complete', + label: 'Complete profile', + description: 'desc', + points: 10, + source: 'provider-a', + }, + ]), + }); + customerProfilesRepository.findByIdOrThrow.mockResolvedValue({ + id: 'profile-1', + userId: 'user-1', + trustScore: 110, + trustLevel: CustomerTrustLevel.YELLOW, + trustScoreUpdatedAt: computedAt, + }); + + const result = await service.getSummaryForProfileId('profile-1'); + + expect(result.score).toBe(110); + expect(result.level).toBe(CustomerTrustLevel.YELLOW); + expect(result.computedAt).toBe(computedAt); + expect(customerProfilesRepository.update).not.toHaveBeenCalled(); + expect(billingNotificationPublisher.publish).not.toHaveBeenCalled(); + }); + + it('publishes a level change when recomputation crosses a threshold', async () => { + registry.register({ + id: 'provider-a', + evaluate: jest.fn().mockResolvedValue([ + { + id: 'active_or_past_subscription', + label: 'Subscription history', + description: 'desc', + points: 25, + source: 'provider-a', + }, + ]), + }); + customerProfilesRepository.findByUserId.mockResolvedValue({ + id: 'profile-1', + userId: 'user-1', + trustLevel: CustomerTrustLevel.RED, + }); + customerProfilesRepository.update.mockImplementation(async (_id: string, dto: Record) => ({ + id: 'profile-1', + userId: 'user-1', + ...dto, + })); + + const result = await service.recomputeForUser('user-1'); + + expect(result?.score).toBe(125); + expect(result?.level).toBe(CustomerTrustLevel.GREEN); + expect(billingNotificationPublisher.publish).toHaveBeenCalledWith( + 'customer_trust.level_changed', + expect.objectContaining({ + userId: 'user-1', + profileId: 'profile-1', + previousLevel: CustomerTrustLevel.RED, + level: CustomerTrustLevel.GREEN, + score: 125, + }), + 'user-1', + ); + }); + + it('returns null when no billing profile exists for the user', async () => { + customerProfilesRepository.findByUserId.mockResolvedValue(null); + + await expect(service.recomputeForUser('user-1')).resolves.toBeNull(); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/customer-trust-score.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/customer-trust-score.service.ts new file mode 100644 index 000000000..4ec31375b --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/customer-trust-score.service.ts @@ -0,0 +1,173 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import type { CustomerProfileEntity } from '../entities/customer-profile.entity'; +import { BillingNotificationPublisher } from '../notifications/billing-notification.publisher'; +import { CustomerProfilesRepository } from '../repositories/customer-profiles.repository'; + +import { + CUSTOMER_TRUST_SCORE_BASE, + CUSTOMER_TRUST_SCORE_MAX, + CUSTOMER_TRUST_SCORE_MIN, + CUSTOMER_TRUST_SCORE_SNAPSHOT_TTL_MS, + CUSTOMER_TRUST_SCORE_THRESHOLDS, +} from './trust-score.constants'; +import { TrustScoreProviderRegistry } from './trust-score-provider.registry'; +import { CustomerTrustLevel, type CustomerTrustScoreSummary } from './trust-score.types'; + +@Injectable() +export class CustomerTrustScoreService { + private readonly logger = new Logger(CustomerTrustScoreService.name); + + constructor( + private readonly customerProfilesRepository: CustomerProfilesRepository, + private readonly trustScoreProviderRegistry: TrustScoreProviderRegistry, + private readonly billingNotificationPublisher: BillingNotificationPublisher, + ) {} + + isSnapshotFresh(profile: Pick): boolean { + if (profile.trustScore == null || !profile.trustLevel || !profile.trustScoreUpdatedAt) { + return false; + } + + return Date.now() - profile.trustScoreUpdatedAt.getTime() <= CUSTOMER_TRUST_SCORE_SNAPSHOT_TTL_MS; + } + + async ensureFreshSnapshot(profile: CustomerProfileEntity): Promise { + if (this.isSnapshotFresh(profile)) { + return profile; + } + + try { + const result = await this.recomputeProfile(profile); + + return result.profile; + } catch (error) { + this.logger.error(`Failed to refresh trust score for profile ${profile.id}`, error); + + return profile; + } + } + + async recomputeForProfileId(profileId: string): Promise { + const profile = await this.customerProfilesRepository.findByIdOrThrow(profileId); + const result = await this.recomputeProfile(profile); + + return result.summary; + } + + async getSummaryForProfileId(profileId: string): Promise { + const profile = await this.customerProfilesRepository.findByIdOrThrow(profileId); + + if (!this.isSnapshotFresh(profile)) { + return await this.recomputeForProfileId(profileId); + } + + return await this.summarizeProfile(profile); + } + + async recomputeForUser(userId: string): Promise { + const profile = await this.customerProfilesRepository.findByUserId(userId); + + if (!profile) { + return null; + } + + const result = await this.recomputeProfile(profile); + + return result.summary; + } + + triggerRecomputeForUser(userId: string): void { + void this.recomputeForUser(userId).catch((error: unknown) => { + this.logger.error(`Failed to recompute trust score for user ${userId}`, error); + }); + } + + private async recomputeProfile( + profile: CustomerProfileEntity, + ): Promise<{ profile: CustomerProfileEntity; summary: CustomerTrustScoreSummary }> { + const factors = await this.evaluateFactors(profile.userId); + const score = this.clampScore( + CUSTOMER_TRUST_SCORE_BASE + factors.reduce((total, factor) => total + factor.points, 0), + ); + const level = this.resolveLevel(score); + const computedAt = new Date(); + const updatedProfile = await this.customerProfilesRepository.update(profile.id, { + trustScore: score, + trustLevel: level, + trustScoreUpdatedAt: computedAt, + }); + + if (profile.trustLevel && profile.trustLevel !== level) { + this.billingNotificationPublisher.publish( + 'customer_trust.level_changed', + { + userId: profile.userId, + profileId: profile.id, + previousLevel: profile.trustLevel, + level, + score, + }, + profile.userId, + ); + } + + return { + profile: updatedProfile, + summary: this.buildSummary(score, level, computedAt, factors), + }; + } + + private async summarizeProfile(profile: CustomerProfileEntity): Promise { + const factors = await this.evaluateFactors(profile.userId); + + return this.buildSummary(profile.trustScore!, profile.trustLevel!, profile.trustScoreUpdatedAt!, factors); + } + + private async evaluateFactors(userId: string): Promise { + return ( + await Promise.all(this.trustScoreProviderRegistry.getProviders().map((provider) => provider.evaluate(userId))) + ).flat(); + } + + private buildSummary( + score: number, + level: CustomerTrustLevel, + computedAt: Date, + factors: CustomerTrustScoreSummary['factors'], + ): CustomerTrustScoreSummary { + return { + score, + level, + baseScore: CUSTOMER_TRUST_SCORE_BASE, + factors: [ + { + id: 'base_score', + label: 'Base score', + description: 'New accounts start from a neutral trust baseline before billing history is applied.', + points: CUSTOMER_TRUST_SCORE_BASE, + source: 'system', + }, + ...factors, + ], + computedAt, + sources: [...new Set(factors.map((factor) => factor.source))], + }; + } + + private clampScore(score: number): number { + return Math.max(CUSTOMER_TRUST_SCORE_MIN, Math.min(CUSTOMER_TRUST_SCORE_MAX, score)); + } + + private resolveLevel(score: number): CustomerTrustLevel { + if (score >= CUSTOMER_TRUST_SCORE_THRESHOLDS[CustomerTrustLevel.GREEN]) { + return CustomerTrustLevel.GREEN; + } + + if (score >= CUSTOMER_TRUST_SCORE_THRESHOLDS[CustomerTrustLevel.YELLOW]) { + return CustomerTrustLevel.YELLOW; + } + + return CustomerTrustLevel.RED; + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/internal-billing-trust-score.provider.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/internal-billing-trust-score.provider.spec.ts new file mode 100644 index 000000000..083d5337f --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/internal-billing-trust-score.provider.spec.ts @@ -0,0 +1,98 @@ +import { SubscriptionStatus } from '../entities/subscription.entity'; + +import { InternalBillingTrustScoreProvider } from './internal-billing-trust-score.provider'; + +describe('InternalBillingTrustScoreProvider', () => { + const customerProfilesRepository = { + findByUserId: jest.fn(), + }; + const customerProfilesService = { + isProfileComplete: jest.fn(), + }; + const invoicesRepository = { + findOpenOverdueByUserId: jest.fn(), + countBilledSubscriptionInvoicesByUserId: jest.fn(), + hasAutoPaymentExhaustedByUserId: jest.fn(), + }; + const paymentAttemptsRepository = { + countSucceededOnTimeByUserId: jest.fn(), + countFailedByUserId: jest.fn(), + }; + const subscriptionsRepository = { + findAllForUserInTenant: jest.fn(), + }; + const backordersRepository = { + countFailedByUserId: jest.fn(), + }; + + const provider = new InternalBillingTrustScoreProvider( + customerProfilesRepository as never, + customerProfilesService as never, + invoicesRepository as never, + paymentAttemptsRepository as never, + subscriptionsRepository as never, + backordersRepository as never, + ); + + beforeEach(() => { + jest.resetAllMocks(); + customerProfilesRepository.findByUserId.mockResolvedValue({ + id: 'profile-1', + autoBillingEnabled: true, + defaultPaymentMethodExternalId: 'pm_1', + stripeCustomerId: 'cus_1', + }); + customerProfilesService.isProfileComplete.mockReturnValue(true); + invoicesRepository.findOpenOverdueByUserId.mockResolvedValue([{ id: 'inv-1' }, { id: 'inv-2' }]); + invoicesRepository.countBilledSubscriptionInvoicesByUserId.mockResolvedValue(2); + invoicesRepository.hasAutoPaymentExhaustedByUserId.mockResolvedValue(true); + paymentAttemptsRepository.countSucceededOnTimeByUserId.mockResolvedValue(3); + paymentAttemptsRepository.countFailedByUserId.mockResolvedValue(2); + subscriptionsRepository.findAllForUserInTenant.mockResolvedValue([ + { id: 'sub-1', status: SubscriptionStatus.ACTIVE, withdrawnAt: null }, + ]); + backordersRepository.countFailedByUserId.mockResolvedValue(2); + }); + + it('builds positive and negative trust factors from billing data', async () => { + const factors = await provider.evaluate('user-1'); + + expect(factors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'profile_complete', points: 10 }), + expect.objectContaining({ id: 'active_or_past_subscription', points: 15 }), + expect.objectContaining({ id: 'multi_period_tenure', points: 20 }), + expect.objectContaining({ id: 'on_time_payments', points: 15 }), + expect.objectContaining({ id: 'auto_billing_ready', points: 10 }), + expect.objectContaining({ id: 'no_withdrawal', points: 10 }), + expect.objectContaining({ id: 'overdue_invoices', points: -30 }), + expect.objectContaining({ id: 'failed_payments', points: -20 }), + expect.objectContaining({ id: 'auto_payment_exhausted', points: -20 }), + expect.objectContaining({ id: 'backorder_failures', points: -10 }), + ]), + ); + }); + + it('caps repeated factors and emits withdrawal penalty when applicable', async () => { + paymentAttemptsRepository.countSucceededOnTimeByUserId.mockResolvedValue(9); + paymentAttemptsRepository.countFailedByUserId.mockResolvedValue(12); + invoicesRepository.findOpenOverdueByUserId.mockResolvedValue(new Array(9).fill({ id: 'inv' })); + backordersRepository.countFailedByUserId.mockResolvedValue(9); + subscriptionsRepository.findAllForUserInTenant.mockResolvedValue([ + { id: 'sub-1', status: SubscriptionStatus.PENDING_WITHDRAWAL, withdrawnAt: new Date() }, + ]); + + const factors = await provider.evaluate('user-1'); + + expect(factors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'on_time_payments', points: 25 }), + expect.objectContaining({ id: 'failed_payments', points: -50 }), + expect.objectContaining({ id: 'overdue_invoices', points: -75 }), + expect.objectContaining({ id: 'backorder_failures', points: -15 }), + expect.objectContaining({ id: 'product_withdrawal', points: -25 }), + ]), + ); + expect(factors.some((factor) => factor.id === 'no_withdrawal')).toBe(false); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/internal-billing-trust-score.provider.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/internal-billing-trust-score.provider.ts new file mode 100644 index 000000000..7855511f4 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/internal-billing-trust-score.provider.ts @@ -0,0 +1,186 @@ +import { Injectable } from '@nestjs/common'; + +import { AutoPaymentStatus } from '../constants/auto-payment-status.constants'; +import { SubscriptionStatus } from '../entities/subscription.entity'; +import { BackordersRepository } from '../repositories/backorders.repository'; +import { CustomerProfilesRepository } from '../repositories/customer-profiles.repository'; +import { InvoicesRepository } from '../repositories/invoices.repository'; +import { PaymentAttemptsRepository } from '../repositories/payment-attempts.repository'; +import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; +import { CustomerProfilesService } from '../services/customer-profiles.service'; + +import { CUSTOMER_TRUST_SCORE_FACTOR_CONFIG } from './trust-score.constants'; +import type { TrustScoreProvider } from './trust-score-provider.interface'; +import type { CustomerTrustScoreFactor } from './trust-score.types'; + +@Injectable() +export class InternalBillingTrustScoreProvider implements TrustScoreProvider { + readonly id = 'internal_billing'; + + constructor( + private readonly customerProfilesRepository: CustomerProfilesRepository, + private readonly customerProfilesService: CustomerProfilesService, + private readonly invoicesRepository: InvoicesRepository, + private readonly paymentAttemptsRepository: PaymentAttemptsRepository, + private readonly subscriptionsRepository: SubscriptionsRepository, + private readonly backordersRepository: BackordersRepository, + ) {} + + async evaluate(userId: string): Promise { + const profile = await this.customerProfilesRepository.findByUserId(userId); + const subscriptions = await this.subscriptionsRepository.findAllForUserInTenant(userId); + const openOverdueInvoices = await this.invoicesRepository.findOpenOverdueByUserId(userId); + const onTimePaymentCount = await this.paymentAttemptsRepository.countSucceededOnTimeByUserId(userId); + const failedPaymentCount = await this.paymentAttemptsRepository.countFailedByUserId(userId); + const billedSubscriptionInvoiceCount = + await this.invoicesRepository.countBilledSubscriptionInvoicesByUserId(userId); + const hasAutoPaymentExhausted = await this.invoicesRepository.hasAutoPaymentExhaustedByUserId(userId); + const backorderFailureCount = await this.backordersRepository.countFailedByUserId(userId); + + const factors: CustomerTrustScoreFactor[] = []; + const hasSubscriptionHistory = subscriptions.length > 0; + const hasWithdrawal = subscriptions.some( + (subscription) => + subscription.status === SubscriptionStatus.PENDING_WITHDRAWAL || subscription.withdrawnAt != null, + ); + + if (profile && this.customerProfilesService.isProfileComplete(profile)) { + factors.push({ + id: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.profileComplete.id, + label: 'Complete billing profile', + description: 'The customer profile contains the billing data required for invoicing and ordering.', + points: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.profileComplete.points, + source: this.id, + }); + } + + if (hasSubscriptionHistory) { + factors.push({ + id: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.activeOrPastSubscription.id, + label: 'Subscription history', + description: 'The customer has at least one active or past subscription in Decabill.', + points: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.activeOrPastSubscription.points, + source: this.id, + metadata: { subscriptionCount: subscriptions.length }, + }); + } + + if (billedSubscriptionInvoiceCount >= 2) { + factors.push({ + id: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.multiPeriodTenure.id, + label: 'Multi-period tenure', + description: 'The customer has already been billed across at least two subscription billing periods.', + points: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.multiPeriodTenure.points, + source: this.id, + metadata: { billedSubscriptionInvoiceCount }, + }); + } + + const onTimePaymentsApplied = Math.min(onTimePaymentCount, CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.onTimePayments.cap); + if (onTimePaymentsApplied > 0) { + factors.push({ + id: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.onTimePayments.id, + label: 'On-time payments', + description: `The customer has ${onTimePaymentsApplied} recorded payment attempt${ + onTimePaymentsApplied === 1 ? '' : 's' + } completed on or before the invoice due date.`, + points: onTimePaymentsApplied * CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.onTimePayments.points, + source: this.id, + metadata: { count: onTimePaymentCount, appliedCount: onTimePaymentsApplied }, + }); + } + + if (profile?.autoBillingEnabled && profile.defaultPaymentMethodExternalId && profile.stripeCustomerId) { + factors.push({ + id: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.autoBillingReady.id, + label: 'Auto-billing ready', + description: 'Auto-billing is enabled and a reusable payment method is on file.', + points: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.autoBillingReady.points, + source: this.id, + }); + } + + if (hasSubscriptionHistory && !hasWithdrawal) { + factors.push({ + id: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.noWithdrawal.id, + label: 'No withdrawals recorded', + description: 'The customer has subscription history without a recorded withdrawal.', + points: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.noWithdrawal.points, + source: this.id, + }); + } + + const overdueInvoiceCount = Math.min( + openOverdueInvoices.length, + CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.overdueInvoices.cap, + ); + if (overdueInvoiceCount > 0) { + factors.push({ + id: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.overdueInvoices.id, + label: 'Open or overdue invoices', + description: `The customer currently has ${openOverdueInvoices.length} open or overdue invoice${ + openOverdueInvoices.length === 1 ? '' : 's' + }.`, + points: overdueInvoiceCount * CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.overdueInvoices.points, + source: this.id, + metadata: { + count: openOverdueInvoices.length, + appliedCount: overdueInvoiceCount, + }, + }); + } + + const failedPaymentsApplied = Math.min(failedPaymentCount, CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.failedPayments.cap); + if (failedPaymentsApplied > 0) { + factors.push({ + id: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.failedPayments.id, + label: 'Failed payments', + description: `The customer has ${failedPaymentCount} failed payment attempt${ + failedPaymentCount === 1 ? '' : 's' + } recorded.`, + points: failedPaymentsApplied * CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.failedPayments.points, + source: this.id, + metadata: { count: failedPaymentCount, appliedCount: failedPaymentsApplied }, + }); + } + + if (hasAutoPaymentExhausted) { + factors.push({ + id: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.autoPaymentExhausted.id, + label: 'Auto-payment exhausted', + description: `At least one invoice reached the auto-payment status "${AutoPaymentStatus.EXHAUSTED}".`, + points: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.autoPaymentExhausted.points, + source: this.id, + }); + } + + if (hasWithdrawal) { + factors.push({ + id: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.productWithdrawal.id, + label: 'Withdrawal recorded', + description: 'A statutory withdrawal was initiated or completed for at least one subscription.', + points: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.productWithdrawal.points, + source: this.id, + }); + } + + const backorderFailuresApplied = Math.min( + backorderFailureCount, + CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.backorderFailures.cap, + ); + if (backorderFailuresApplied > 0) { + factors.push({ + id: CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.backorderFailures.id, + label: 'Backorder failures', + description: `The customer has ${backorderFailureCount} failed backorder attempt${ + backorderFailureCount === 1 ? '' : 's' + }.`, + points: backorderFailuresApplied * CUSTOMER_TRUST_SCORE_FACTOR_CONFIG.backorderFailures.points, + source: this.id, + metadata: { count: backorderFailureCount, appliedCount: backorderFailuresApplied }, + }); + } + + return factors; + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score-provider.interface.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score-provider.interface.ts new file mode 100644 index 000000000..d5262e21a --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score-provider.interface.ts @@ -0,0 +1,6 @@ +import type { CustomerTrustScoreFactor } from './trust-score.types'; + +export interface TrustScoreProvider { + id: string; + evaluate(userId: string): Promise; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score-provider.registry.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score-provider.registry.ts new file mode 100644 index 000000000..acdf7a923 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score-provider.registry.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; + +import type { TrustScoreProvider } from './trust-score-provider.interface'; + +@Injectable() +export class TrustScoreProviderRegistry { + private readonly providers = new Map(); + + register(provider: TrustScoreProvider): void { + this.providers.set(provider.id, provider); + } + + getProviders(): TrustScoreProvider[] { + return Array.from(this.providers.values()); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score.constants.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score.constants.ts new file mode 100644 index 000000000..c5f83f638 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score.constants.ts @@ -0,0 +1,63 @@ +import { CustomerTrustLevel } from './trust-score.types'; + +export const CUSTOMER_TRUST_SCORE_BASE = 100; +export const CUSTOMER_TRUST_SCORE_MIN = 0; +export const CUSTOMER_TRUST_SCORE_MAX = 200; +export const CUSTOMER_TRUST_SCORE_SNAPSHOT_TTL_MS = 60 * 60 * 1000; + +export const CUSTOMER_TRUST_SCORE_THRESHOLDS: Record = { + [CustomerTrustLevel.GREEN]: 120, + [CustomerTrustLevel.YELLOW]: 70, + [CustomerTrustLevel.RED]: 0, +}; + +export const CUSTOMER_TRUST_SCORE_FACTOR_CONFIG = { + profileComplete: { + id: 'profile_complete', + points: 10, + }, + activeOrPastSubscription: { + id: 'active_or_past_subscription', + points: 15, + }, + multiPeriodTenure: { + id: 'multi_period_tenure', + points: 20, + }, + onTimePayments: { + id: 'on_time_payments', + points: 5, + cap: 5, + }, + autoBillingReady: { + id: 'auto_billing_ready', + points: 10, + }, + noWithdrawal: { + id: 'no_withdrawal', + points: 10, + }, + overdueInvoices: { + id: 'overdue_invoices', + points: -15, + cap: 5, + }, + failedPayments: { + id: 'failed_payments', + points: -10, + cap: 5, + }, + autoPaymentExhausted: { + id: 'auto_payment_exhausted', + points: -20, + }, + productWithdrawal: { + id: 'product_withdrawal', + points: -25, + }, + backorderFailures: { + id: 'backorder_failures', + points: -5, + cap: 3, + }, +} as const; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score.types.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score.types.ts new file mode 100644 index 000000000..f2c650a0b --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/trust-score/trust-score.types.ts @@ -0,0 +1,23 @@ +export enum CustomerTrustLevel { + GREEN = 'green', + YELLOW = 'yellow', + RED = 'red', +} + +export interface CustomerTrustScoreFactor { + id: string; + label: string; + description: string; + points: number; + source: string; + metadata?: Record; +} + +export interface CustomerTrustScoreSummary { + score: number; + level: CustomerTrustLevel; + baseScore: number; + factors: CustomerTrustScoreFactor[]; + computedAt: Date; + sources: string[]; +} diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-customer-profiles.service.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-customer-profiles.service.spec.ts index 93d6ed366..a659594de 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-customer-profiles.service.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-customer-profiles.service.spec.ts @@ -48,6 +48,29 @@ describe('AdminCustomerProfilesService', () => { req.flush({ id: 'profile-1', userId: 'user-1', isComplete: true, createdAt: '', updatedAt: '' }); }); + it('gets trust score by profile id', (done) => { + service.getTrustScore('profile-1').subscribe((res) => { + expect(res.profileId).toBe('profile-1'); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/admin/billing/customer-profiles/profile-1/trust-score`); + + expect(req.request.method).toBe('GET'); + req.flush({ profileId: 'profile-1', userId: 'user-1', score: 120, level: 'green', baseScore: 100, factors: [] }); + }); + + it('recomputes trust score by profile id', (done) => { + service.recomputeTrustScore('profile-1').subscribe((res) => { + expect(res.score).toBe(95); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/admin/billing/customer-profiles/profile-1/trust-score/recompute`); + + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({}); + req.flush({ profileId: 'profile-1', userId: 'user-1', score: 95, level: 'yellow', baseScore: 100, factors: [] }); + }); + it('creates profile', (done) => { const dto = { userId: 'user-1', firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' }; diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-customer-profiles.service.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-customer-profiles.service.ts index 679405a2d..e326dc8b1 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-customer-profiles.service.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-customer-profiles.service.ts @@ -7,6 +7,7 @@ import { Observable } from 'rxjs'; import type { AdminCustomerProfileDetail, AdminCustomerProfileListItem, + CustomerTrustScoreDetail, CreateAdminCustomerProfileDto, CustomerProfileDto, CustomerProfileResponse, @@ -41,6 +42,17 @@ export class AdminCustomerProfilesService { return this.http.get(`${this.apiUrl}/admin/billing/customer-profiles/${id}`); } + getTrustScore(id: string): Observable { + return this.http.get(`${this.apiUrl}/admin/billing/customer-profiles/${id}/trust-score`); + } + + recomputeTrustScore(id: string): Observable { + return this.http.post( + `${this.apiUrl}/admin/billing/customer-profiles/${id}/trust-score/recompute`, + {}, + ); + } + create(dto: CreateAdminCustomerProfileDto): Observable { return this.http.post(`${this.apiUrl}/admin/billing/customer-profiles`, dto); } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.actions.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.actions.ts index 068261fb9..f3d455cc4 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.actions.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.actions.ts @@ -2,6 +2,7 @@ import { createAction, props } from '@ngrx/store'; import type { AdminCustomerProfileListItem, + CustomerTrustScoreDetail, CreateAdminCustomerProfileDto, CustomerProfileDto, CustomerProfileResponse, @@ -59,3 +60,29 @@ export const deleteAdminCustomerProfileFailure = createAction( '[AdminCustomerProfiles] Delete Profile Failure', props<{ error: string }>(), ); + +export const loadAdminCustomerProfileTrustScore = createAction( + '[AdminCustomerProfiles] Load Trust Score', + props<{ id: string }>(), +); +export const loadAdminCustomerProfileTrustScoreSuccess = createAction( + '[AdminCustomerProfiles] Load Trust Score Success', + props<{ detail: CustomerTrustScoreDetail }>(), +); +export const loadAdminCustomerProfileTrustScoreFailure = createAction( + '[AdminCustomerProfiles] Load Trust Score Failure', + props<{ error: string }>(), +); + +export const recomputeAdminCustomerProfileTrustScore = createAction( + '[AdminCustomerProfiles] Recompute Trust Score', + props<{ id: string }>(), +); +export const recomputeAdminCustomerProfileTrustScoreSuccess = createAction( + '[AdminCustomerProfiles] Recompute Trust Score Success', + props<{ detail: CustomerTrustScoreDetail }>(), +); +export const recomputeAdminCustomerProfileTrustScoreFailure = createAction( + '[AdminCustomerProfiles] Recompute Trust Score Failure', + props<{ error: string }>(), +); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.effects.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.effects.spec.ts index f6144923e..1dd8f4b06 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.effects.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.effects.spec.ts @@ -14,8 +14,14 @@ import { deleteAdminCustomerProfileSuccess, loadAdminCustomerProfiles, loadAdminCustomerProfilesBatch, + loadAdminCustomerProfileTrustScore, + loadAdminCustomerProfileTrustScoreFailure, + loadAdminCustomerProfileTrustScoreSuccess, loadAdminCustomerProfilesFailure, loadAdminCustomerProfilesSuccess, + recomputeAdminCustomerProfileTrustScore, + recomputeAdminCustomerProfileTrustScoreFailure, + recomputeAdminCustomerProfileTrustScoreSuccess, updateAdminCustomerProfile, updateAdminCustomerProfileFailure, updateAdminCustomerProfileSuccess, @@ -25,6 +31,8 @@ import { deleteAdminCustomerProfile$, loadAdminCustomerProfiles$, loadAdminCustomerProfilesBatch$, + loadAdminCustomerProfileTrustScore$, + recomputeAdminCustomerProfileTrustScore$, updateAdminCustomerProfile$, } from './admin-customer-profiles.effects'; @@ -42,6 +50,8 @@ describe('AdminCustomerProfilesEffects', () => { beforeEach(() => { service = { list: jest.fn(), + getTrustScore: jest.fn(), + recomputeTrustScore: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), @@ -214,4 +224,52 @@ describe('AdminCustomerProfilesEffects', () => { }); }); }); + + describe('loadAdminCustomerProfileTrustScore$', () => { + it('returns success', (done) => { + const detail = { profileId: 'p-1', userId: 'u-1', score: 120, level: 'green', baseScore: 100, factors: [] }; + + actions$ = of(loadAdminCustomerProfileTrustScore({ id: 'p-1' })); + service.getTrustScore.mockReturnValue(of(detail as never)); + + loadAdminCustomerProfileTrustScore$(actions$, service).subscribe((result) => { + expect(result).toEqual(loadAdminCustomerProfileTrustScoreSuccess({ detail: detail as never })); + done(); + }); + }); + + it('returns failure on error', (done) => { + actions$ = of(loadAdminCustomerProfileTrustScore({ id: 'p-1' })); + service.getTrustScore.mockReturnValue(throwError(() => new Error('Trust load failed'))); + + loadAdminCustomerProfileTrustScore$(actions$, service).subscribe((result) => { + expect(result).toEqual(loadAdminCustomerProfileTrustScoreFailure({ error: 'Trust load failed' })); + done(); + }); + }); + }); + + describe('recomputeAdminCustomerProfileTrustScore$', () => { + it('returns success', (done) => { + const detail = { profileId: 'p-1', userId: 'u-1', score: 95, level: 'yellow', baseScore: 100, factors: [] }; + + actions$ = of(recomputeAdminCustomerProfileTrustScore({ id: 'p-1' })); + service.recomputeTrustScore.mockReturnValue(of(detail as never)); + + recomputeAdminCustomerProfileTrustScore$(actions$, service).subscribe((result) => { + expect(result).toEqual(recomputeAdminCustomerProfileTrustScoreSuccess({ detail: detail as never })); + done(); + }); + }); + + it('returns failure on error', (done) => { + actions$ = of(recomputeAdminCustomerProfileTrustScore({ id: 'p-1' })); + service.recomputeTrustScore.mockReturnValue(throwError(() => new Error('Trust recompute failed'))); + + recomputeAdminCustomerProfileTrustScore$(actions$, service).subscribe((result) => { + expect(result).toEqual(recomputeAdminCustomerProfileTrustScoreFailure({ error: 'Trust recompute failed' })); + done(); + }); + }); + }); }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.effects.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.effects.ts index 2cab2c9cf..35a552fd1 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.effects.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.effects.ts @@ -13,8 +13,14 @@ import { deleteAdminCustomerProfileSuccess, loadAdminCustomerProfiles, loadAdminCustomerProfilesBatch, + loadAdminCustomerProfileTrustScore, + loadAdminCustomerProfileTrustScoreFailure, + loadAdminCustomerProfileTrustScoreSuccess, loadAdminCustomerProfilesFailure, loadAdminCustomerProfilesSuccess, + recomputeAdminCustomerProfileTrustScore, + recomputeAdminCustomerProfileTrustScoreFailure, + recomputeAdminCustomerProfileTrustScoreSuccess, updateAdminCustomerProfile, updateAdminCustomerProfileFailure, updateAdminCustomerProfileSuccess, @@ -124,3 +130,31 @@ export const deleteAdminCustomerProfile$ = createEffect( ), { functional: true }, ); + +export const loadAdminCustomerProfileTrustScore$ = createEffect( + (actions$ = inject(Actions), service = inject(AdminCustomerProfilesService)) => + actions$.pipe( + ofType(loadAdminCustomerProfileTrustScore), + switchMap(({ id }) => + service.getTrustScore(id).pipe( + map((detail) => loadAdminCustomerProfileTrustScoreSuccess({ detail })), + catchError((error) => of(loadAdminCustomerProfileTrustScoreFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); + +export const recomputeAdminCustomerProfileTrustScore$ = createEffect( + (actions$ = inject(Actions), service = inject(AdminCustomerProfilesService)) => + actions$.pipe( + ofType(recomputeAdminCustomerProfileTrustScore), + switchMap(({ id }) => + service.recomputeTrustScore(id).pipe( + map((detail) => recomputeAdminCustomerProfileTrustScoreSuccess({ detail })), + catchError((error) => of(recomputeAdminCustomerProfileTrustScoreFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.facade.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.facade.spec.ts index 9189b23a5..a2a4a9b4a 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.facade.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.facade.spec.ts @@ -5,6 +5,8 @@ import { createAdminCustomerProfile, deleteAdminCustomerProfile, loadAdminCustomerProfiles, + loadAdminCustomerProfileTrustScore, + recomputeAdminCustomerProfileTrustScore, updateAdminCustomerProfile, } from './admin-customer-profiles.actions'; import { AdminCustomerProfilesFacade } from './admin-customer-profiles.facade'; @@ -49,4 +51,16 @@ describe('AdminCustomerProfilesFacade', () => { expect(store.dispatch).toHaveBeenCalledWith(deleteAdminCustomerProfile({ id: 'p-1' })); }); + + it('loadTrustScore dispatches loadAdminCustomerProfileTrustScore', () => { + facade.loadTrustScore('p-1'); + + expect(store.dispatch).toHaveBeenCalledWith(loadAdminCustomerProfileTrustScore({ id: 'p-1' })); + }); + + it('recomputeTrustScore dispatches recomputeAdminCustomerProfileTrustScore', () => { + facade.recomputeTrustScore('p-1'); + + expect(store.dispatch).toHaveBeenCalledWith(recomputeAdminCustomerProfileTrustScore({ id: 'p-1' })); + }); }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.facade.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.facade.ts index ac0cc78e3..9e13b7664 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.facade.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.facade.ts @@ -7,11 +7,16 @@ import { createAdminCustomerProfile, deleteAdminCustomerProfile, loadAdminCustomerProfiles, + loadAdminCustomerProfileTrustScore, + recomputeAdminCustomerProfileTrustScore, updateAdminCustomerProfile, } from './admin-customer-profiles.actions'; import { selectAdminCustomerProfiles, selectAdminCustomerProfilesCreating, + selectAdminCustomerProfileTrustScoreDetail, + selectAdminCustomerProfileTrustScoreLoading, + selectAdminCustomerProfileTrustScoreRefreshing, selectAdminCustomerProfilesDeleting, selectAdminCustomerProfilesError, selectAdminCustomerProfilesLoading, @@ -28,6 +33,9 @@ export class AdminCustomerProfilesFacade { readonly updating$ = this.store.select(selectAdminCustomerProfilesUpdating); readonly deleting$ = this.store.select(selectAdminCustomerProfilesDeleting); readonly error$ = this.store.select(selectAdminCustomerProfilesError); + readonly trustScoreDetail$ = this.store.select(selectAdminCustomerProfileTrustScoreDetail); + readonly trustScoreLoading$ = this.store.select(selectAdminCustomerProfileTrustScoreLoading); + readonly trustScoreRefreshing$ = this.store.select(selectAdminCustomerProfileTrustScoreRefreshing); loadProfiles(): void { this.store.dispatch(loadAdminCustomerProfiles()); @@ -44,4 +52,12 @@ export class AdminCustomerProfilesFacade { deleteProfile(id: string): void { this.store.dispatch(deleteAdminCustomerProfile({ id })); } + + loadTrustScore(id: string): void { + this.store.dispatch(loadAdminCustomerProfileTrustScore({ id })); + } + + recomputeTrustScore(id: string): void { + this.store.dispatch(recomputeAdminCustomerProfileTrustScore({ id })); + } } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.reducer.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.reducer.spec.ts index ece4b2abc..41207e77c 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.reducer.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.reducer.spec.ts @@ -7,8 +7,14 @@ import { deleteAdminCustomerProfileSuccess, loadAdminCustomerProfiles, loadAdminCustomerProfilesBatch, + loadAdminCustomerProfileTrustScore, + loadAdminCustomerProfileTrustScoreFailure, + loadAdminCustomerProfileTrustScoreSuccess, loadAdminCustomerProfilesFailure, loadAdminCustomerProfilesSuccess, + recomputeAdminCustomerProfileTrustScore, + recomputeAdminCustomerProfileTrustScoreFailure, + recomputeAdminCustomerProfileTrustScoreSuccess, updateAdminCustomerProfile, updateAdminCustomerProfileFailure, updateAdminCustomerProfileSuccess, @@ -23,6 +29,16 @@ describe('adminCustomerProfilesReducer', () => { createdAt: '2024-01-01', updatedAt: '2024-01-01', }; + const trustDetail = { + profileId: 'p-1', + userId: 'u-1', + score: 120, + level: 'green' as const, + baseScore: 100, + computedAt: '2024-01-03', + factors: [], + sources: ['internal_billing'], + }; const profileResponse = { id: 'p-1', userId: 'u-1', @@ -144,4 +160,47 @@ describe('adminCustomerProfilesReducer', () => { expect(state.deleting).toBe(false); expect(state.error).toBe('Delete failed'); }); + + it('stores trust detail on load success', () => { + const loadingState = adminCustomerProfilesReducer( + { ...initialAdminCustomerProfilesState, profiles: [listItem] }, + loadAdminCustomerProfileTrustScore({ id: 'p-1' }), + ); + const state = adminCustomerProfilesReducer( + loadingState, + loadAdminCustomerProfileTrustScoreSuccess({ detail: trustDetail }), + ); + + expect(state.trustScoreLoading).toBe(false); + expect(state.trustScoreDetail?.score).toBe(120); + expect(state.profiles[0].trustLevel).toBe('green'); + }); + + it('stores trust detail on recompute success', () => { + const refreshingState = adminCustomerProfilesReducer( + { ...initialAdminCustomerProfilesState, profiles: [listItem] }, + recomputeAdminCustomerProfileTrustScore({ id: 'p-1' }), + ); + const state = adminCustomerProfilesReducer( + refreshingState, + recomputeAdminCustomerProfileTrustScoreSuccess({ detail: trustDetail }), + ); + + expect(state.trustScoreRefreshing).toBe(false); + expect(state.trustScoreDetail?.level).toBe('green'); + }); + + it('stores trust errors', () => { + const loadErrorState = adminCustomerProfilesReducer( + { ...initialAdminCustomerProfilesState, trustScoreLoading: true }, + loadAdminCustomerProfileTrustScoreFailure({ error: 'Trust load failed' }), + ); + const recomputeErrorState = adminCustomerProfilesReducer( + { ...initialAdminCustomerProfilesState, trustScoreRefreshing: true }, + recomputeAdminCustomerProfileTrustScoreFailure({ error: 'Trust recompute failed' }), + ); + + expect(loadErrorState.error).toBe('Trust load failed'); + expect(recomputeErrorState.error).toBe('Trust recompute failed'); + }); }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.reducer.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.reducer.ts index 827cbe606..808294cef 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.reducer.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.reducer.ts @@ -1,6 +1,10 @@ import { createReducer, on } from '@ngrx/store'; -import type { AdminCustomerProfileListItem, CustomerProfileResponse } from '../../types/billing.types'; +import type { + AdminCustomerProfileListItem, + CustomerProfileResponse, + CustomerTrustScoreDetail, +} from '../../types/billing.types'; import { createAdminCustomerProfile, @@ -11,8 +15,14 @@ import { deleteAdminCustomerProfileSuccess, loadAdminCustomerProfiles, loadAdminCustomerProfilesBatch, + loadAdminCustomerProfileTrustScore, + loadAdminCustomerProfileTrustScoreFailure, + loadAdminCustomerProfileTrustScoreSuccess, loadAdminCustomerProfilesFailure, loadAdminCustomerProfilesSuccess, + recomputeAdminCustomerProfileTrustScore, + recomputeAdminCustomerProfileTrustScoreFailure, + recomputeAdminCustomerProfileTrustScoreSuccess, updateAdminCustomerProfile, updateAdminCustomerProfileFailure, updateAdminCustomerProfileSuccess, @@ -24,6 +34,9 @@ export interface AdminCustomerProfilesState { creating: boolean; updating: boolean; deleting: boolean; + trustScoreDetail: CustomerTrustScoreDetail | null; + trustScoreLoading: boolean; + trustScoreRefreshing: boolean; error: string | null; } @@ -33,6 +46,9 @@ export const initialAdminCustomerProfilesState: AdminCustomerProfilesState = { creating: false, updating: false, deleting: false, + trustScoreDetail: null, + trustScoreLoading: false, + trustScoreRefreshing: false, error: null, }; @@ -56,12 +72,29 @@ function mapResponseToListItem(profile: CustomerProfileResponse): AdminCustomerP }; } +function applyTrustDetail( + profile: AdminCustomerProfileListItem, + detail: Pick, +): AdminCustomerProfileListItem { + if (profile.id !== detail.profileId) { + return profile; + } + + return { + ...profile, + trustScore: detail.score, + trustLevel: detail.level, + trustScoreUpdatedAt: detail.computedAt, + }; +} + export const adminCustomerProfilesReducer = createReducer( initialAdminCustomerProfilesState, on(loadAdminCustomerProfiles, (state) => ({ ...state, profiles: [], loading: true, + trustScoreDetail: null, error: null, })), on(loadAdminCustomerProfilesBatch, (state, { accumulatedProfiles }) => ({ @@ -113,4 +146,39 @@ export const adminCustomerProfilesReducer = createReducer( profiles: state.profiles.filter((profile) => profile.id !== id), })), on(deleteAdminCustomerProfileFailure, (state, { error }) => ({ ...state, deleting: false, error })), + on(loadAdminCustomerProfileTrustScore, (state) => ({ + ...state, + trustScoreLoading: true, + trustScoreDetail: null, + error: null, + })), + on(loadAdminCustomerProfileTrustScoreSuccess, (state, { detail }) => ({ + ...state, + trustScoreLoading: false, + trustScoreDetail: detail, + profiles: state.profiles.map((profile) => applyTrustDetail(profile, detail)), + error: null, + })), + on(loadAdminCustomerProfileTrustScoreFailure, (state, { error }) => ({ + ...state, + trustScoreLoading: false, + error, + })), + on(recomputeAdminCustomerProfileTrustScore, (state) => ({ + ...state, + trustScoreRefreshing: true, + error: null, + })), + on(recomputeAdminCustomerProfileTrustScoreSuccess, (state, { detail }) => ({ + ...state, + trustScoreRefreshing: false, + trustScoreDetail: detail, + profiles: state.profiles.map((profile) => applyTrustDetail(profile, detail)), + error: null, + })), + on(recomputeAdminCustomerProfileTrustScoreFailure, (state, { error }) => ({ + ...state, + trustScoreRefreshing: false, + error, + })), ); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.selectors.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.selectors.spec.ts index 03516c797..5733c306f 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.selectors.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.selectors.spec.ts @@ -1,6 +1,9 @@ import type { AdminCustomerProfilesState } from './admin-customer-profiles.reducer'; import { selectAdminCustomerProfiles, + selectAdminCustomerProfileTrustScoreDetail, + selectAdminCustomerProfileTrustScoreLoading, + selectAdminCustomerProfileTrustScoreRefreshing, selectAdminCustomerProfilesCreating, selectAdminCustomerProfilesDeleting, selectAdminCustomerProfilesError, @@ -16,6 +19,18 @@ describe('adminCustomerProfilesSelectors', () => { creating: true, updating: true, deleting: true, + trustScoreDetail: { + profileId: 'p-1', + userId: 'u-1', + score: 120, + level: 'green', + baseScore: 100, + factors: [], + computedAt: '', + sources: [], + }, + trustScoreLoading: true, + trustScoreRefreshing: true, error: 'err', }; const rootState = { adminCustomerProfiles: state }; @@ -38,4 +53,10 @@ describe('adminCustomerProfilesSelectors', () => { it('selects error', () => { expect(selectAdminCustomerProfilesError(rootState as never)).toBe('err'); }); + + it('selects trust score detail state', () => { + expect(selectAdminCustomerProfileTrustScoreDetail(rootState as never)).toEqual(state.trustScoreDetail); + expect(selectAdminCustomerProfileTrustScoreLoading(rootState as never)).toBe(true); + expect(selectAdminCustomerProfileTrustScoreRefreshing(rootState as never)).toBe(true); + }); }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.selectors.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.selectors.ts index 9bb69f66a..335cd3389 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.selectors.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-customer-profiles/admin-customer-profiles.selectors.ts @@ -31,3 +31,18 @@ export const selectAdminCustomerProfilesError = createSelector( selectAdminCustomerProfilesState, (state) => state.error, ); + +export const selectAdminCustomerProfileTrustScoreDetail = createSelector( + selectAdminCustomerProfilesState, + (state) => state.trustScoreDetail, +); + +export const selectAdminCustomerProfileTrustScoreLoading = createSelector( + selectAdminCustomerProfilesState, + (state) => state.trustScoreLoading, +); + +export const selectAdminCustomerProfileTrustScoreRefreshing = createSelector( + selectAdminCustomerProfilesState, + (state) => state.trustScoreRefreshing, +); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts index 477af2215..8ddfd2a6b 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts @@ -854,6 +854,9 @@ export interface AdminCustomerProfileListItem { country?: string; isComplete: boolean; stripeCustomerId?: string; + trustScore?: number; + trustLevel?: CustomerTrustLevel; + trustScoreUpdatedAt?: string; createdAt: string; updatedAt: string; } @@ -872,6 +875,31 @@ export interface CreateAdminCustomerProfileDto extends CustomerProfileDto { export interface AdminCustomerProfileDetail extends CustomerProfileResponse { userEmail?: string; isComplete: boolean; + trustScore?: number; + trustLevel?: CustomerTrustLevel; + trustScoreUpdatedAt?: string; +} + +export type CustomerTrustLevel = 'green' | 'yellow' | 'red'; + +export interface CustomerTrustScoreFactor { + id: string; + label: string; + description: string; + points: number; + source: string; + metadata?: Record; +} + +export interface CustomerTrustScoreDetail { + profileId: string; + userId: string; + score: number; + level: CustomerTrustLevel; + baseScore: number; + factors: CustomerTrustScoreFactor[]; + computedAt: string; + sources: string[]; } export type DatevExportScope = 'tenant' | 'unified'; diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-customer-profiles-page/admin-customer-profiles-page.component.html b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-customer-profiles-page/admin-customer-profiles-page.component.html index 0043085c3..dd47fd11d 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-customer-profiles-page/admin-customer-profiles-page.component.html +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-customer-profiles-page/admin-customer-profiles-page.component.html @@ -52,6 +52,29 @@
Billing Profile }
+ + + + + {{ profileTrustLabel(profile.trustLevel) }} + + @if (profile.isComplete) { @@ -78,6 +101,15 @@
Billing Profile aria-label="Profile actions" i18n-aria-label="@@featureAdminProfiles-profileActionsAria" > +
+ +