Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions migration/1779967217063-AddAktionariatRegistration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddAktionariatRegistration1779967217063 {
name = 'AddAktionariatRegistration1779967217063'

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "aktionariat_registration" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "walletAddress" character varying(256) NOT NULL, "status" character varying(256) NOT NULL, "signature" text NOT NULL, "registrationDate" character varying(256) NOT NULL, "externalRef" character varying(256), "comment" text, "result" text, "userId" integer NOT NULL, "userDataId" integer NOT NULL, "kycStepId" integer, CONSTRAINT "PK_af158ecdcaff6229223fe33f2ee" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_21d1d4854aa5b13f2038752af0" ON "aktionariat_registration" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_071503c6951cd10097327edffc" ON "aktionariat_registration" ("userDataId") `);
await queryRunner.query(`CREATE INDEX "IDX_63e3c8146435a6c91532b45e0e" ON "aktionariat_registration" ("kycStepId") `);
await queryRunner.query(`CREATE INDEX "IDX_754aadd3add69f81ce36ecfe33" ON "aktionariat_registration" ("walletAddress") `);
await queryRunner.query(`ALTER TABLE "aktionariat_registration" ADD CONSTRAINT "FK_21d1d4854aa5b13f2038752af00" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "aktionariat_registration" ADD CONSTRAINT "FK_071503c6951cd10097327edffc6" FOREIGN KEY ("userDataId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "aktionariat_registration" ADD CONSTRAINT "FK_63e3c8146435a6c91532b45e0e9" FOREIGN KEY ("kycStepId") REFERENCES "kyc_step"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);

// Backfill from existing kyc_step rows (latest non-failed step per wallet)
await queryRunner.query(`
INSERT INTO "aktionariat_registration" ("walletAddress", "status", "signature", "registrationDate", "result", "userId", "userDataId", "kycStepId")
SELECT DISTINCT ON (LOWER(ks."result"::json->>'walletAddress'))
ks."result"::json->>'walletAddress',
ks."status",
ks."result"::json->>'signature',
COALESCE(ks."result"::json->>'registrationDate', ''),
ks."result",
u."id",
ks."userDataId",
ks."id"
FROM "kyc_step" ks
JOIN "user" u ON LOWER(u."address") = LOWER(ks."result"::json->>'walletAddress')
WHERE ks."name" = 'RealUnitRegistration'
AND ks."status" NOT IN ('Failed', 'Canceled')
AND ks."result" IS NOT NULL
AND ks."result"::json->>'walletAddress' IS NOT NULL
ORDER BY LOWER(ks."result"::json->>'walletAddress'), ks."id" DESC
`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "aktionariat_registration" DROP CONSTRAINT "FK_63e3c8146435a6c91532b45e0e9"`);
await queryRunner.query(`ALTER TABLE "aktionariat_registration" DROP CONSTRAINT "FK_071503c6951cd10097327edffc6"`);
await queryRunner.query(`ALTER TABLE "aktionariat_registration" DROP CONSTRAINT "FK_21d1d4854aa5b13f2038752af00"`);
await queryRunner.query(`DROP INDEX "public"."IDX_754aadd3add69f81ce36ecfe33"`);
await queryRunner.query(`DROP INDEX "public"."IDX_63e3c8146435a6c91532b45e0e"`);
await queryRunner.query(`DROP INDEX "public"."IDX_071503c6951cd10097327edffc"`);
await queryRunner.query(`DROP INDEX "public"."IDX_21d1d4854aa5b13f2038752af0"`);
await queryRunner.query(`DROP TABLE "aktionariat_registration"`);
}
}
2 changes: 1 addition & 1 deletion migration/generate.sh
Original file line number Diff line number Diff line change
@@ -1 +1 @@
typeorm migration:generate migration/$1 -o -d migration/dev-data-source.ts
typeorm migration:generate migration/$1 -o -d migration/dev-data-source.psql.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createMock } from '@golevelup/ts-jest';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service';
Expand Down Expand Up @@ -29,6 +30,7 @@ import { PricingService } from '../../pricing/services/pricing.service';
import { RealUnitRegistrationStatus } from '../dto/realunit-registration.dto';
import { RealUnitDevService } from '../realunit-dev.service';
import { RealUnitService } from '../realunit.service';
import { AktionariatRegistrationRepository } from '../repositories/aktionariat-registration.repository';

jest.mock('src/config/config', () => ({
get Config() {
Expand Down Expand Up @@ -101,6 +103,7 @@ describe('RealUnitService', () => {
let sellService: jest.Mocked<SellService>;
let userService: jest.Mocked<UserService>;
let kycService: jest.Mocked<KycService>;
let module: TestingModule;

const realuAsset = createCustomAsset({
id: 1,
Expand All @@ -121,7 +124,7 @@ describe('RealUnitService', () => {
});

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
module = await Test.createTestingModule({
providers: [
RealUnitService,
{ provide: AssetPricesService, useValue: {} },
Expand Down Expand Up @@ -184,6 +187,7 @@ describe('RealUnitService', () => {
{ provide: FaucetRequestService, useValue: {} },
{ provide: EthereumService, useValue: {} },
{ provide: SepoliaService, useValue: {} },
{ provide: AktionariatRegistrationRepository, useValue: createMock<AktionariatRegistrationRepository>() },
],
}).compile();

Expand Down Expand Up @@ -395,26 +399,39 @@ describe('RealUnitService', () => {
const matchingSignature = '0xSIGNATURE_MATCHING';
const registrationDate = '2026-05-21';

function buildExistingStep(opts: { signature: string; isCompleted: boolean }): any {
let registrationRepo: AktionariatRegistrationRepository;

beforeEach(() => {
registrationRepo = module.get(AktionariatRegistrationRepository);
});

function buildExistingRegistration(opts: { signature: string; isCompleted: boolean }): any {
return {
getResult: () => ({
signature: opts.signature,
walletAddress,
registrationDate,
}),
isCompleted: opts.isCompleted,
isFailed: false,
isCanceled: false,
result: 'non-empty',
id: 1,
signature: opts.signature,
walletAddress,
registrationDate,
status: opts.isCompleted ? 'Completed' : 'InternalReview',
get isCompleted() {
return this.status === 'Completed';
},
get isFailed() {
return this.status === 'Failed';
},
get isCanceled() {
return this.status === 'Canceled';
},
result: JSON.stringify({ signature: opts.signature, walletAddress, registrationDate }),
getResult() {
return JSON.parse(this.result);
},
};
}

function mockUserWithSteps(steps: any[]): void {
const userData = {
id: userDataId,
getStepsWith: jest.fn().mockReturnValue(steps),
};
function mockUserWithRegistration(registration: any): void {
const userData = { id: userDataId };
userService.getUserByAddress.mockResolvedValue({ userData } as any);
jest.spyOn(registrationRepo, 'findOneBy').mockResolvedValue(registration);
}

const dto = {
Expand All @@ -424,8 +441,8 @@ describe('RealUnitService', () => {
};

it('returns ALREADY_REGISTERED without creating a new KycStep when signature matches a completed registration', async () => {
const existingStep = buildExistingStep({ signature: matchingSignature, isCompleted: true });
mockUserWithSteps([existingStep]);
const existing = buildExistingRegistration({ signature: matchingSignature, isCompleted: true });
mockUserWithRegistration(existing);

const status = await service.completeRegistrationForWalletAddress(userDataId, dto);

Expand All @@ -434,8 +451,8 @@ describe('RealUnitService', () => {
});

it('returns FORWARDING_FAILED when signature matches but the existing registration is not completed', async () => {
const existingStep = buildExistingStep({ signature: matchingSignature, isCompleted: false });
mockUserWithSteps([existingStep]);
const existing = buildExistingRegistration({ signature: matchingSignature, isCompleted: false });
mockUserWithRegistration(existing);

const status = await service.completeRegistrationForWalletAddress(userDataId, dto);

Expand All @@ -444,8 +461,8 @@ describe('RealUnitService', () => {
});

it('matches signatures case-insensitively (stored upper-case, incoming lower-case)', async () => {
const existingStep = buildExistingStep({ signature: matchingSignature.toUpperCase(), isCompleted: true });
mockUserWithSteps([existingStep]);
const existing = buildExistingRegistration({ signature: matchingSignature.toUpperCase(), isCompleted: true });
mockUserWithRegistration(existing);

const status = await service.completeRegistrationForWalletAddress(userDataId, {
...dto,
Expand All @@ -457,8 +474,8 @@ describe('RealUnitService', () => {
});

it('throws BadRequestException when an existing registration for the same wallet has a different signature', async () => {
const existingStep = buildExistingStep({ signature: '0xDIFFERENT_SIGNATURE', isCompleted: true });
mockUserWithSteps([existingStep]);
const existing = buildExistingRegistration({ signature: '0xDIFFERENT_SIGNATURE', isCompleted: true });
mockUserWithRegistration(existing);

await expect(service.completeRegistrationForWalletAddress(userDataId, dto)).rejects.toThrow(BadRequestException);
expect(kycService.createCustomKycStep).not.toHaveBeenCalled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ export class RealUnitController {
@ApiOkResponse({ type: RealUnitPaymentInfoDto })
@ApiBadRequestResponse({ description: 'KYC Level 30 required, registration missing, or address not on allowlist' })
async getPaymentInfo(@GetJwt() jwt: JwtPayload, @Body() dto: RealUnitBuyDto): Promise<RealUnitPaymentInfoDto> {
const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true, country: true } });
const user = await this.userService.getUser(jwt.user, { userData: { country: true } });
return this.realunitService.getPaymentInfo(user, dto);
}

Expand Down Expand Up @@ -546,7 +546,7 @@ export class RealUnitController {
@GetJwt() jwt: JwtPayload,
@Body() dto: RealUnitSellDto,
): Promise<RealUnitSellPaymentInfoDto> {
const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true, country: true } });
const user = await this.userService.getUser(jwt.user, { userData: { country: true } });
return this.realunitService.getSellPaymentInfo(user, dto);
}

Expand Down Expand Up @@ -616,8 +616,8 @@ export class RealUnitController {
})
@ApiOkResponse({ type: RealUnitWalletStatusDto })
async getWalletStatus(@GetJwt() jwt: JwtPayload): Promise<RealUnitWalletStatusDto> {
const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true } });
return this.realunitService.getAddressWalletStatus(user.userData, jwt.address);
const user = await this.userService.getUser(jwt.user, { userData: true });
return this.realunitService.getAddressWalletStatus(user.userData.id, jwt.address);
}

// --- Registration Endpoints ---
Expand All @@ -631,8 +631,8 @@ export class RealUnitController {
})
@ApiOkResponse({ type: Boolean })
async isRegistered(@GetJwt() jwt: JwtPayload): Promise<boolean> {
const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true } });
return this.realunitService.hasRegistrationForWallet(user.userData, jwt.address);
const user = await this.userService.getUser(jwt.user, { userData: true });
return this.realunitService.hasRegistrationForWallet(user.userData.id, jwt.address);
}

@Post('register/email')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { IEntity, UpdateResult } from 'src/shared/models/entity';
import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum';
import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity';
import { User } from 'src/subdomains/generic/user/models/user/user.entity';
import { Column, Entity, Index, ManyToOne } from 'typeorm';
import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity';

@Entity('aktionariat_registration')
export class AktionariatRegistration extends IEntity {
@Index({ unique: true })
@ManyToOne(() => User, { nullable: false })
user: User;

@Index()
@ManyToOne(() => UserData, { nullable: false })
userData: UserData;

@Index()
@ManyToOne(() => KycStep, { nullable: true })
kycStep?: KycStep;

@Column({ length: 256 })
@Index()
walletAddress: string;

@Column({ length: 256 })
status: ReviewStatus;

@Column({ type: 'text' })
signature: string;

@Column({ length: 256 })
registrationDate: string;

@Column({ length: 256, nullable: true })
externalRef?: string;

@Column({ type: 'text', nullable: true })
comment?: string;

@Column({ type: 'text', nullable: true })
result?: string;

// --- GETTERS --- //

get isCompleted(): boolean {
return this.status === ReviewStatus.COMPLETED;
}

get isFailed(): boolean {
return this.status === ReviewStatus.FAILED;
}

get isCanceled(): boolean {
return this.status === ReviewStatus.CANCELED;
}

getResult<T>(): T | undefined {
if (!this.result) return undefined;
try {
return JSON.parse(this.result);
} catch {
return undefined;
}
}

// --- UPDATE METHODS --- //

complete(): UpdateResult<AktionariatRegistration> {
const update: Partial<AktionariatRegistration> = { status: ReviewStatus.COMPLETED };
return [this.id, update];
}

manualReview(comment?: string): UpdateResult<AktionariatRegistration> {
const update: Partial<AktionariatRegistration> = { status: ReviewStatus.MANUAL_REVIEW, comment };
return [this.id, update];
}
}
6 changes: 5 additions & 1 deletion src/subdomains/supporting/realunit/realunit.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EthereumModule } from 'src/integration/blockchain/ethereum/ethereum.module';
import { RealUnitBlockchainModule } from 'src/integration/blockchain/realunit/realunit-blockchain.module';
import { SepoliaModule } from 'src/integration/blockchain/sepolia/sepolia.module';
Expand All @@ -16,11 +17,14 @@ import { PaymentModule } from '../payment/payment.module';
import { TransactionModule } from '../payment/transaction.module';
import { PricingModule } from '../pricing/pricing.module';
import { RealUnitController } from './controllers/realunit.controller';
import { AktionariatRegistration } from './entities/aktionariat-registration.entity';
import { RealUnitDevService } from './realunit-dev.service';
import { RealUnitService } from './realunit.service';
import { AktionariatRegistrationRepository } from './repositories/aktionariat-registration.repository';

@Module({
imports: [
TypeOrmModule.forFeature([AktionariatRegistration]),
SharedModule,
PricingModule,
BalanceModule,
Expand All @@ -39,7 +43,7 @@ import { RealUnitService } from './realunit.service';
FaucetRequestModule,
],
controllers: [RealUnitController],
providers: [RealUnitService, RealUnitDevService],
providers: [RealUnitService, RealUnitDevService, AktionariatRegistrationRepository],
exports: [RealUnitService],
})
export class RealUnitModule {}
Loading