From 4e855a40e6e252ddb9981f2b6f12454c5bc3985b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:08:16 +0200 Subject: [PATCH] feat(realunit): version legal-disclaimer steps via partner consent Persist disclaimer acceptance server-side so each of the five RealUnit onboarding disclaimer steps is versioned independently. The user is only re-prompted for a step whose required version they have not accepted yet. Add a generic partner-consent domain: an append-only consent log keyed by userData + partner (the existing Wallet entity) + topic + version, plus a service that reports missing topics and records acceptances. RealUnit wires it up via GET/PUT /v1/realunit/disclaimer, with the required versions held in config so the backend stays the single source of truth and the app only renders the returned steps. The companion realunit-app PR (consuming the endpoints and removing the local in-memory gate) follows after this merges to develop. --- migration/1781031292273-AddPartnerConsent.js | 34 +++++ src/config/config.ts | 14 ++ .../__tests__/partner-consent.service.spec.ts | 125 ++++++++++++++++++ .../partner-consent/partner-consent.entity.ts | 26 ++++ .../partner-consent/partner-consent.module.ts | 12 ++ .../partner-consent.repository.ts | 11 ++ .../partner-consent.service.ts | 54 ++++++++ .../__tests__/realunit.service.spec.ts | 85 +++++++++++- .../controllers/realunit.controller.ts | 31 +++++ .../realunit/dto/realunit-disclaimer.dto.ts | 26 ++++ .../enums/realunit-disclaimer-topic.enum.ts | 12 ++ .../supporting/realunit/realunit.module.ts | 2 + .../supporting/realunit/realunit.service.ts | 30 +++++ 13 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 migration/1781031292273-AddPartnerConsent.js create mode 100644 src/subdomains/generic/partner-consent/__tests__/partner-consent.service.spec.ts create mode 100644 src/subdomains/generic/partner-consent/partner-consent.entity.ts create mode 100644 src/subdomains/generic/partner-consent/partner-consent.module.ts create mode 100644 src/subdomains/generic/partner-consent/partner-consent.repository.ts create mode 100644 src/subdomains/generic/partner-consent/partner-consent.service.ts create mode 100644 src/subdomains/supporting/realunit/dto/realunit-disclaimer.dto.ts create mode 100644 src/subdomains/supporting/realunit/enums/realunit-disclaimer-topic.enum.ts diff --git a/migration/1781031292273-AddPartnerConsent.js b/migration/1781031292273-AddPartnerConsent.js new file mode 100644 index 0000000000..5a0ae9e62b --- /dev/null +++ b/migration/1781031292273-AddPartnerConsent.js @@ -0,0 +1,34 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddPartnerConsent1781031292273 { + name = 'AddPartnerConsent1781031292273' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "partner_consent" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "topic" character varying(256) NOT NULL, "version" integer NOT NULL, "partnerId" integer NOT NULL, "userDataId" integer NOT NULL, CONSTRAINT "PK_67707b8dfd2fe11b9709673d326" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_d7d7bafbd3629778eb1376d1ab" ON "partner_consent" ("partnerId") `); + await queryRunner.query(`CREATE INDEX "IDX_f6629dbad0f2d3a7cab345f229" ON "partner_consent" ("userDataId") `); + await queryRunner.query(`ALTER TABLE "partner_consent" ADD CONSTRAINT "FK_d7d7bafbd3629778eb1376d1ab3" FOREIGN KEY ("partnerId") REFERENCES "wallet"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "partner_consent" ADD CONSTRAINT "FK_f6629dbad0f2d3a7cab345f2294" FOREIGN KEY ("userDataId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "partner_consent" DROP CONSTRAINT "FK_f6629dbad0f2d3a7cab345f2294"`); + await queryRunner.query(`ALTER TABLE "partner_consent" DROP CONSTRAINT "FK_d7d7bafbd3629778eb1376d1ab3"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f6629dbad0f2d3a7cab345f229"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d7d7bafbd3629778eb1376d1ab"`); + await queryRunner.query(`DROP TABLE "partner_consent"`); + } +} diff --git a/src/config/config.ts b/src/config/config.ts index 290d3faef5..e9d85f17e3 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -21,6 +21,7 @@ import { KycIdentificationType } from 'src/subdomains/generic/user/models/user-d import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { LegalEntity } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; import { MailOptions } from 'src/subdomains/supporting/notification/services/mail.service'; +import { RealUnitDisclaimerTopic } from 'src/subdomains/supporting/realunit/enums/realunit-disclaimer-topic.enum'; import { LoggerOptions } from 'typeorm'; import { EVM_CHAINS } from './chains.config'; @@ -1074,6 +1075,19 @@ export class Configuration { city: process.env.REALUNIT_ADDRESS_CITY ?? 'Baar', country: process.env.REALUNIT_ADDRESS_COUNTRY ?? 'Switzerland', }, + // Current required version per legal-disclaimer step. Bump a value when the + // corresponding content (i18n text or linked documents in the app) changes + // — the user is then re-prompted for that step only. Keep in sync with the + // app deployment that ships the new content. + disclaimer: { + versions: { + [RealUnitDisclaimerTopic.DISCLAIMER_PART_1]: 1, + [RealUnitDisclaimerTopic.DISCLAIMER_PART_2]: 1, + [RealUnitDisclaimerTopic.REALUNIT_DOCUMENTS]: 1, + [RealUnitDisclaimerTopic.AKTIONARIAT_DOCUMENTS]: 1, + [RealUnitDisclaimerTopic.DFX_DOCUMENTS]: 1, + } as Record, + }, }, ebel2x: { contractAddress: process.env.EBEL2X_CONTRACT_ADDRESS, diff --git a/src/subdomains/generic/partner-consent/__tests__/partner-consent.service.spec.ts b/src/subdomains/generic/partner-consent/__tests__/partner-consent.service.spec.ts new file mode 100644 index 0000000000..ff0646eae7 --- /dev/null +++ b/src/subdomains/generic/partner-consent/__tests__/partner-consent.service.spec.ts @@ -0,0 +1,125 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; +import { PartnerConsentRepository } from '../partner-consent.repository'; +import { PartnerConsentService } from '../partner-consent.service'; + +describe('PartnerConsentService', () => { + let service: PartnerConsentService; + let repo: jest.Mocked; + let queryBuilder: { + select: jest.Mock; + addSelect: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + groupBy: jest.Mock; + getRawMany: jest.Mock; + }; + + const partner = Object.assign(new Wallet(), { id: 5 }); + const userData = Object.assign(new UserData(), { id: 10 }); + + beforeEach(async () => { + queryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PartnerConsentService, + { + provide: PartnerConsentRepository, + useValue: { + createQueryBuilder: jest.fn(() => queryBuilder), + create: jest.fn((x) => x), + save: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(PartnerConsentService); + repo = module.get(PartnerConsentRepository); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('getConfirmedVersions', () => { + it('maps the highest version per topic and filters by user and partner id', async () => { + queryBuilder.getRawMany.mockResolvedValue([ + { topic: 'A', version: 2 }, + { topic: 'B', version: 1 }, + ]); + + const result = await service.getConfirmedVersions(userData, partner); + + expect(result).toEqual( + new Map([ + ['A', 2], + ['B', 1], + ]), + ); + expect(queryBuilder.where).toHaveBeenCalledWith('consent.userDataId = :userDataId', { userDataId: 10 }); + expect(queryBuilder.andWhere).toHaveBeenCalledWith('consent.partnerId = :partnerId', { partnerId: 5 }); + expect(queryBuilder.groupBy).toHaveBeenCalledWith('consent.topic'); + }); + }); + + describe('getMissingTopics', () => { + it('returns topics whose required version exceeds the confirmed version', async () => { + queryBuilder.getRawMany.mockResolvedValue([ + { topic: 'A', version: 2 }, + { topic: 'B', version: 1 }, + ]); + + const required = new Map([ + ['A', 2], + ['B', 2], + ['C', 1], + ]); + const result = await service.getMissingTopics(userData, partner, required); + + // A: confirmed 2 >= required 2 -> ok; B: 1 < 2 -> missing; C: nothing < 1 -> missing + expect(result).toEqual(['B', 'C']); + }); + + it('treats every required topic as missing when nothing is confirmed', async () => { + queryBuilder.getRawMany.mockResolvedValue([]); + + const required = new Map([ + ['A', 1], + ['B', 1], + ]); + const result = await service.getMissingTopics(userData, partner, required); + + expect(result).toEqual(['A', 'B']); + }); + }); + + describe('confirm', () => { + it('appends one row per entry referencing the user and partner id', async () => { + await service.confirm(userData, partner, [ + { topic: 'A', version: 1 }, + { topic: 'B', version: 3 }, + ]); + + expect(repo.create).toHaveBeenCalledTimes(2); + expect(repo.save).toHaveBeenCalledWith([ + { userData: { id: 10 }, partner: { id: 5 }, topic: 'A', version: 1 }, + { userData: { id: 10 }, partner: { id: 5 }, topic: 'B', version: 3 }, + ]); + }); + + it('does nothing for an empty entry list', async () => { + await service.confirm(userData, partner, []); + + expect(repo.create).not.toHaveBeenCalled(); + expect(repo.save).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/subdomains/generic/partner-consent/partner-consent.entity.ts b/src/subdomains/generic/partner-consent/partner-consent.entity.ts new file mode 100644 index 0000000000..adbe70168a --- /dev/null +++ b/src/subdomains/generic/partner-consent/partner-consent.entity.ts @@ -0,0 +1,26 @@ +import { IEntity } from 'src/shared/models/entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; +import { Column, Entity, Index, ManyToOne } from 'typeorm'; + +// Append-only record of a user accepting a versioned consent topic for a given +// partner (the DFX-side "partner" is the existing Wallet entity — see +// docs/partner-consent). The latest accepted version of a topic is the highest +// `version` for a (userData, partner, topic) tuple; older rows are kept as the +// legal audit trail of when which version was accepted. +@Entity() +export class PartnerConsent extends IEntity { + @Index() + @ManyToOne(() => Wallet, { nullable: false }) + partner: Wallet; + + @Index() + @ManyToOne(() => UserData, { nullable: false }) + userData: UserData; + + @Column({ length: 256 }) + topic: string; + + @Column({ type: 'int' }) + version: number; +} diff --git a/src/subdomains/generic/partner-consent/partner-consent.module.ts b/src/subdomains/generic/partner-consent/partner-consent.module.ts new file mode 100644 index 0000000000..d30dd83aaf --- /dev/null +++ b/src/subdomains/generic/partner-consent/partner-consent.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PartnerConsent } from './partner-consent.entity'; +import { PartnerConsentRepository } from './partner-consent.repository'; +import { PartnerConsentService } from './partner-consent.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([PartnerConsent])], + providers: [PartnerConsentRepository, PartnerConsentService], + exports: [PartnerConsentService], +}) +export class PartnerConsentModule {} diff --git a/src/subdomains/generic/partner-consent/partner-consent.repository.ts b/src/subdomains/generic/partner-consent/partner-consent.repository.ts new file mode 100644 index 0000000000..e73468e643 --- /dev/null +++ b/src/subdomains/generic/partner-consent/partner-consent.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { PartnerConsent } from './partner-consent.entity'; + +@Injectable() +export class PartnerConsentRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(PartnerConsent, manager); + } +} diff --git a/src/subdomains/generic/partner-consent/partner-consent.service.ts b/src/subdomains/generic/partner-consent/partner-consent.service.ts new file mode 100644 index 0000000000..6c04f7d977 --- /dev/null +++ b/src/subdomains/generic/partner-consent/partner-consent.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; +import { PartnerConsentRepository } from './partner-consent.repository'; + +export interface PartnerConsentEntry { + topic: string; + version: number; +} + +@Injectable() +export class PartnerConsentService { + constructor(private readonly repo: PartnerConsentRepository) {} + + // Highest accepted version per topic for the (userData, partner) pair. + async getConfirmedVersions(userData: UserData, partner: Wallet): Promise> { + const rows = await this.repo + .createQueryBuilder('consent') + .select('consent.topic', 'topic') + .addSelect('MAX(consent.version)', 'version') + .where('consent.userDataId = :userDataId', { userDataId: userData.id }) + .andWhere('consent.partnerId = :partnerId', { partnerId: partner.id }) + .groupBy('consent.topic') + .getRawMany<{ topic: string; version: number }>(); + + return new Map(rows.map((r) => [r.topic, Number(r.version)])); + } + + // Topics whose required version the user has not accepted yet. The caller owns + // the source of truth for required versions (e.g. partner config); this service + // only compares it against what is stored. + async getMissingTopics(userData: UserData, partner: Wallet, required: Map): Promise { + const confirmed = await this.getConfirmedVersions(userData, partner); + return [...required.entries()] + .filter(([topic, version]) => (confirmed.get(topic) ?? 0) < version) + .map(([topic]) => topic); + } + + // Append-only: one new row per confirmed topic, stamped with the version the + // caller passes in (the partner-side current version). + async confirm(userData: UserData, partner: Wallet, entries: PartnerConsentEntry[]): Promise { + if (!entries.length) return; + + const consents = entries.map((e) => + this.repo.create({ + userData: { id: userData.id }, + partner: { id: partner.id }, + topic: e.topic, + version: e.version, + }), + ); + await this.repo.save(consents); + } +} diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index ac87826230..cbe8c42bf2 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException } from '@nestjs/common'; +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Wallet } from 'ethers'; import { verifyTypedData } from 'ethers/lib/utils'; @@ -19,9 +19,12 @@ import { HttpService } from 'src/shared/services/http.service'; import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; import { SellService } from 'src/subdomains/core/sell-crypto/route/sell.service'; import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; +import { PartnerConsentService } from 'src/subdomains/generic/partner-consent/partner-consent.service'; import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { Wallet as PartnerWallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; import { FeeService } from 'src/subdomains/supporting/payment/services/fee.service'; import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; @@ -29,6 +32,7 @@ import { TransactionService } from 'src/subdomains/supporting/payment/services/t import { AssetPricesService } from '../../pricing/services/asset-prices.service'; import { PricingService } from '../../pricing/services/pricing.service'; import { RealUnitRegistrationState, RealUnitRegistrationStatus } from '../dto/realunit-registration.dto'; +import { RealUnitDisclaimerTopic } from '../enums/realunit-disclaimer-topic.enum'; import { PriceInvalidException } from '../../pricing/domain/exceptions/price-invalid.exception'; import { RealUnitDevService } from '../realunit-dev.service'; import { PriceSourceUnavailableException } from '../exceptions/price-source-unavailable.exception'; @@ -56,6 +60,15 @@ jest.mock('src/config/config', () => ({ brokerbotAddress: '0xBrokerbotAddress', graphUrl: 'https://mock-ponder.example.com', api: { url: 'https://mock-api.example.com', key: 'mock-key' }, + disclaimer: { + versions: { + DisclaimerPart1: 1, + DisclaimerPart2: 1, + RealUnitDocuments: 1, + AktionariatDocuments: 1, + DfxDocuments: 1, + }, + }, }, ethereum: { ethChainId: 1 }, sepolia: { sepoliaChainId: 11155111 }, @@ -113,6 +126,7 @@ describe('RealUnitService', () => { let sellService: jest.Mocked; let userService: jest.Mocked; let kycService: jest.Mocked; + let partnerConsentService: jest.Mocked; const realuAsset = createCustomAsset({ id: 1, @@ -197,6 +211,13 @@ describe('RealUnitService', () => { { provide: FaucetRequestService, useValue: {} }, { provide: EthereumService, useValue: {} }, { provide: SepoliaService, useValue: {} }, + { + provide: PartnerConsentService, + useValue: { + getMissingTopics: jest.fn(), + confirm: jest.fn(), + }, + }, ], }).compile(); @@ -208,6 +229,7 @@ describe('RealUnitService', () => { sellService = module.get(SellService); userService = module.get(UserService); kycService = module.get(KycService); + partnerConsentService = module.get(PartnerConsentService); }); afterEach(() => { @@ -803,4 +825,65 @@ describe('RealUnitService', () => { expect((service as any).resolveSignedRegistrationMessage(dto)).toBeUndefined(); }); }); + + describe('getDisclaimerStatus', () => { + const partner = Object.assign(new PartnerWallet(), { id: 5 }); + const userData = Object.assign(new UserData(), { id: 10, wallet: partner }); + + it('returns missing steps in canonical wizard order regardless of query order', async () => { + partnerConsentService.getMissingTopics.mockResolvedValue([ + RealUnitDisclaimerTopic.DFX_DOCUMENTS, + RealUnitDisclaimerTopic.DISCLAIMER_PART_1, + ]); + + const result = await service.getDisclaimerStatus(userData); + + expect(result.requiredSteps).toEqual([ + RealUnitDisclaimerTopic.DISCLAIMER_PART_1, + RealUnitDisclaimerTopic.DFX_DOCUMENTS, + ]); + expect(partnerConsentService.getMissingTopics).toHaveBeenCalledWith(userData, partner, expect.any(Map)); + }); + + it('returns an empty list when nothing is missing', async () => { + partnerConsentService.getMissingTopics.mockResolvedValue([]); + + const result = await service.getDisclaimerStatus(userData); + + expect(result.requiredSteps).toEqual([]); + }); + + it('throws when the user has no partner', async () => { + const noPartner = Object.assign(new UserData(), { id: 11 }); + + await expect(service.getDisclaimerStatus(noPartner)).rejects.toThrow(NotFoundException); + expect(partnerConsentService.getMissingTopics).not.toHaveBeenCalled(); + }); + }); + + describe('confirmDisclaimer', () => { + const partner = Object.assign(new PartnerWallet(), { id: 5 }); + const userData = Object.assign(new UserData(), { id: 10, wallet: partner }); + + it('confirms the given steps stamped with the configured versions', async () => { + await service.confirmDisclaimer(userData, [ + RealUnitDisclaimerTopic.DISCLAIMER_PART_1, + RealUnitDisclaimerTopic.DFX_DOCUMENTS, + ]); + + expect(partnerConsentService.confirm).toHaveBeenCalledWith(userData, partner, [ + { topic: RealUnitDisclaimerTopic.DISCLAIMER_PART_1, version: 1 }, + { topic: RealUnitDisclaimerTopic.DFX_DOCUMENTS, version: 1 }, + ]); + }); + + it('throws when the user has no partner', async () => { + const noPartner = Object.assign(new UserData(), { id: 11 }); + + await expect(service.confirmDisclaimer(noPartner, [RealUnitDisclaimerTopic.DISCLAIMER_PART_1])).rejects.toThrow( + NotFoundException, + ); + expect(partnerConsentService.confirm).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index b4c3e4b663..dccba6178d 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -39,6 +39,7 @@ import { BalancePdfService } from '../../balance/services/balance-pdf.service'; import { SwissQRService } from '../../payment/services/swiss-qr.service'; import { PriceCurrency, PricingService } from '../../pricing/services/pricing.service'; import { RealUnitAdminQueryDto, RealUnitQuoteDto, RealUnitTransactionDto } from '../dto/realunit-admin.dto'; +import { ConfirmDisclaimerDto, RealUnitDisclaimerStatusDto } from '../dto/realunit-disclaimer.dto'; import { RealUnitBalancePdfDto, RealUnitMultiReceiptPdfDto, @@ -622,6 +623,36 @@ export class RealUnitController { return this.realunitService.getRegistrationInfo(user.userData, jwt.address); } + // --- Disclaimer Consent Endpoints --- + + @Get('disclaimer') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get pending RealUnit legal-disclaimer steps', + description: + 'Returns the legal-disclaimer steps whose current version the user has not accepted yet, in wizard order (`requiredSteps`). The client renders exactly these and skips the disclaimer when the list is empty. The backend is the single source of truth for which steps/versions are required.', + }) + @ApiOkResponse({ type: RealUnitDisclaimerStatusDto }) + async getDisclaimerStatus(@GetJwt() jwt: JwtPayload): Promise { + const user = await this.userService.getUser(jwt.user, { userData: { wallet: true } }); + return this.realunitService.getDisclaimerStatus(user.userData); + } + + @Put('disclaimer') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Confirm RealUnit legal-disclaimer steps', + description: + 'Records the user accepting the given disclaimer steps. The server stamps each with the current required version (append-only audit trail); the client does not send versions.', + }) + @ApiOkResponse({ description: 'Disclaimer steps confirmed' }) + async confirmDisclaimer(@GetJwt() jwt: JwtPayload, @Body() dto: ConfirmDisclaimerDto): Promise { + const user = await this.userService.getUser(jwt.user, { userData: { wallet: true } }); + return this.realunitService.confirmDisclaimer(user.userData, dto.steps); + } + @Get('wallet/status') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) diff --git a/src/subdomains/supporting/realunit/dto/realunit-disclaimer.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-disclaimer.dto.ts new file mode 100644 index 0000000000..54f58c4a51 --- /dev/null +++ b/src/subdomains/supporting/realunit/dto/realunit-disclaimer.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsEnum } from 'class-validator'; +import { RealUnitDisclaimerTopic } from '../enums/realunit-disclaimer-topic.enum'; + +export class RealUnitDisclaimerStatusDto { + @ApiProperty({ + enum: RealUnitDisclaimerTopic, + isArray: true, + description: + 'Legal-disclaimer steps whose current version the user has not accepted yet, in the order the wizard must show them. Empty = nothing to confirm, the client skips the disclaimer. The backend is the single source of truth for required steps/versions.', + }) + requiredSteps: RealUnitDisclaimerTopic[]; +} + +export class ConfirmDisclaimerDto { + @ApiProperty({ + enum: RealUnitDisclaimerTopic, + isArray: true, + description: + 'Disclaimer steps the user accepted in this session. The server stamps each with the current required version — the client does not send versions.', + }) + @IsArray() + @ArrayNotEmpty() + @IsEnum(RealUnitDisclaimerTopic, { each: true }) + steps: RealUnitDisclaimerTopic[]; +} diff --git a/src/subdomains/supporting/realunit/enums/realunit-disclaimer-topic.enum.ts b/src/subdomains/supporting/realunit/enums/realunit-disclaimer-topic.enum.ts new file mode 100644 index 0000000000..e2eb712649 --- /dev/null +++ b/src/subdomains/supporting/realunit/enums/realunit-disclaimer-topic.enum.ts @@ -0,0 +1,12 @@ +// The five legal-disclaimer steps the RealUnit app shows during onboarding, each +// versioned independently via the generic partner-consent mechanism. The string +// values are the wire contract with the app and the `topic` stored in +// `partner_consent`. The enum declaration order is the canonical wizard order in +// which missing steps are returned to the client. +export enum RealUnitDisclaimerTopic { + DISCLAIMER_PART_1 = 'DisclaimerPart1', + DISCLAIMER_PART_2 = 'DisclaimerPart2', + REALUNIT_DOCUMENTS = 'RealUnitDocuments', + AKTIONARIAT_DOCUMENTS = 'AktionariatDocuments', + DFX_DOCUMENTS = 'DfxDocuments', +} diff --git a/src/subdomains/supporting/realunit/realunit.module.ts b/src/subdomains/supporting/realunit/realunit.module.ts index a7d7a20cd6..b11f811c6e 100644 --- a/src/subdomains/supporting/realunit/realunit.module.ts +++ b/src/subdomains/supporting/realunit/realunit.module.ts @@ -8,6 +8,7 @@ import { BuyCryptoModule } from 'src/subdomains/core/buy-crypto/buy-crypto.modul import { FaucetRequestModule } from 'src/subdomains/core/faucet-request/faucet-request.module'; import { SellCryptoModule } from 'src/subdomains/core/sell-crypto/sell-crypto.module'; import { KycModule } from 'src/subdomains/generic/kyc/kyc.module'; +import { PartnerConsentModule } from 'src/subdomains/generic/partner-consent/partner-consent.module'; import { UserModule } from 'src/subdomains/generic/user/user.module'; import { BalanceModule } from '../balance/balance.module'; import { BankTxModule } from '../bank-tx/bank-tx.module'; @@ -29,6 +30,7 @@ import { RealUnitService } from './realunit.service'; SepoliaModule, UserModule, KycModule, + PartnerConsentModule, BankModule, BankTxModule, PaymentModule, diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index de4390e88b..6a0763b31d 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -46,6 +46,7 @@ import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum import { KycContext } from 'src/subdomains/generic/kyc/enums/kyc.enum'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; +import { PartnerConsentService } from 'src/subdomains/generic/partner-consent/partner-consent.service'; import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; @@ -71,6 +72,7 @@ import { TokenInfoClientResponse, } from './dto/client.dto'; import { RealUnitQuoteDto, RealUnitTransactionDto } from './dto/realunit-admin.dto'; +import { RealUnitDisclaimerStatusDto } from './dto/realunit-disclaimer.dto'; import { RealUnitDtoMapper } from './dto/realunit-dto.mapper'; import { AktionariatRegistrationDto, @@ -103,6 +105,7 @@ import { TokenInfoDto, } from './dto/realunit.dto'; import { PriceInvalidException } from '../pricing/domain/exceptions/price-invalid.exception'; +import { RealUnitDisclaimerTopic } from './enums/realunit-disclaimer-topic.enum'; import { KycLevelRequiredException, RegistrationRequiredException } from './exceptions/buy-exceptions'; import { PriceSourceUnavailableException } from './exceptions/price-source-unavailable.exception'; import { RealUnitDevService } from './realunit-dev.service'; @@ -198,6 +201,7 @@ export class RealUnitService { private readonly swissQrService: SwissQRService, private readonly feeService: FeeService, private readonly faucetRequestService: FaucetRequestService, + private readonly partnerConsentService: PartnerConsentService, ) { this.ponderUrl = GetConfig().blockchain.realunit.graphUrl; } @@ -740,6 +744,32 @@ export class RealUnitService { }; } + // --- DISCLAIMER CONSENT --- // + + async getDisclaimerStatus(userData: UserData): Promise { + const partner = userData.wallet; + if (!partner) throw new NotFoundException('Partner not found for user'); + + const required = new Map(Object.entries(this.disclaimerVersions)); + const missing = await this.partnerConsentService.getMissingTopics(userData, partner, required); + + // Return in canonical wizard order (enum declaration order), not query order. + return { requiredSteps: Object.values(RealUnitDisclaimerTopic).filter((topic) => missing.includes(topic)) }; + } + + async confirmDisclaimer(userData: UserData, steps: RealUnitDisclaimerTopic[]): Promise { + const partner = userData.wallet; + if (!partner) throw new NotFoundException('Partner not found for user'); + + const versions = this.disclaimerVersions; + const entries = steps.map((topic) => ({ topic, version: versions[topic] })); + await this.partnerConsentService.confirm(userData, partner, entries); + } + + private get disclaimerVersions(): Record { + return GetConfig().blockchain.realunit.disclaimer.versions; + } + async completeRegistrationForWalletAddress( userDataId: number, dto: RealUnitRegisterWalletDto,