Skip to content
Closed
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
102 changes: 102 additions & 0 deletions migration/1779370800171-AddLegalDocumentAndCompanyInfo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Adds the `legal_document` and `company_info` reference tables and the
* `country.displayOrder` column, then seeds:
* - the legal-document URLs currently hardcoded in the realunit-app
* (`lib/packages/config/legal_documents_config.dart:69-122`),
* - the RealUnit company contact info hardcoded in
* (`lib/screens/settings_contact/settings_contact_page.dart:82-134`),
* - the priority country order the realunit-app's country picker
* uses (`lib/widgets/form/country_field.dart:65-79` —
* `['CH','DE','IT','FR']`).
*
* Closes V14, V17, V18 in
* `DFXswiss/realunit-app:docs/api-authority-audit.md`.
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddLegalDocumentAndCompanyInfo1779370800171 {
name = 'AddLegalDocumentAndCompanyInfo1779370800171';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
// --- legal_document table ---
await queryRunner.query(
`CREATE TABLE "legal_document" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "type" character varying(64) NOT NULL, "language" character varying(8), "version" character varying(32) NOT NULL, "url" character varying(1024) NOT NULL, "enabled" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_950166ad59d051a80cad8337c76" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_c3408d5be699dbdc15f529c2ce" ON "legal_document" ("type", "language") WHERE "enabled" = true`,
);

// --- company_info table ---
await queryRunner.query(
`CREATE TABLE "company_info" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "brand" character varying(64) NOT NULL, "name" character varying(256) NOT NULL, "phone" character varying(64), "email" character varying(256), "website" character varying(256), "addressStreet" character varying(256), "addressZip" character varying(64), "addressCity" character varying(256), "addressCountry" character varying(8), "enabled" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_88c3e323679d0747ffbb83f3f78" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_9a97b98884dfa6dfb6c8cc7b11" ON "company_info" ("brand") WHERE "enabled" = true`,
);

// --- country.displayOrder column ---
await queryRunner.query(`ALTER TABLE "country" ADD "displayOrder" integer NOT NULL DEFAULT 999`);
// Seed the priority order the realunit-app used to hardcode:
// const priorityCountries = ['CH', 'DE', 'IT', 'FR']
await queryRunner.query(`UPDATE "country" SET "displayOrder" = 1 WHERE "symbol" = 'CH'`);
await queryRunner.query(`UPDATE "country" SET "displayOrder" = 2 WHERE "symbol" = 'DE'`);
await queryRunner.query(`UPDATE "country" SET "displayOrder" = 3 WHERE "symbol" = 'IT'`);
await queryRunner.query(`UPDATE "country" SET "displayOrder" = 4 WHERE "symbol" = 'FR'`);

// --- seed legal_document with the URLs the realunit-app used to ship hardcoded ---
const legalDocs = [
{ type: 'RegistrationAgreement', language: 'de', version: '1.0', url: 'https://realunit.de/agreement-de.pdf' },
{ type: 'RegistrationAgreement', language: 'en', version: '1.0', url: 'https://realunit.de/agreement-en.pdf' },
{ type: 'Prospectus', language: 'de', version: '1.0', url: 'https://realunit.de/prospekt-de.pdf' },
{ type: 'Prospectus', language: 'en', version: '1.0', url: 'https://realunit.ch/prospectus-en.pdf' },
{ type: 'AktionariatTerms', language: null, version: '1.0', url: 'https://www.aktionariat.com/terms' },
{ type: 'AktionariatPrivacy', language: null, version: '1.0', url: 'https://www.aktionariat.com/privacy' },
{ type: 'DfxTerms', language: null, version: '1.0', url: 'https://docs.dfx.swiss/en/terms.html' },
{ type: 'DfxPrivacy', language: null, version: '1.0', url: 'https://docs.dfx.swiss/en/privacy-policy.html' },
];
for (const doc of legalDocs) {
await queryRunner.query(
`INSERT INTO "legal_document" ("type", "language", "version", "url", "enabled") VALUES ($1, $2, $3, $4, true)`,
[doc.type, doc.language, doc.version, doc.url],
);
}

// --- seed company_info with the contact info the realunit-app used to ship hardcoded ---
await queryRunner.query(
`INSERT INTO "company_info" ("brand", "name", "phone", "email", "website", "addressStreet", "addressZip", "addressCity", "addressCountry", "enabled") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true)`,
[
'RealUnit',
'RealUnit Schweiz AG',
'+41 41 761 00 90',
'info@realunit.ch',
'realunit.ch',
'Schochenmühlestrasse 6',
'6340',
'Baar',
'CH',
],
);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "country" DROP COLUMN "displayOrder"`);

await queryRunner.query(`DROP INDEX "public"."IDX_9a97b98884dfa6dfb6c8cc7b11"`);
await queryRunner.query(`DROP TABLE "company_info"`);

await queryRunner.query(`DROP INDEX "public"."IDX_c3408d5be699dbdc15f529c2ce"`);
await queryRunner.query(`DROP TABLE "legal_document"`);
}
};
18 changes: 18 additions & 0 deletions src/shared/models/company-info/company-info.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CompanyInfoDtoMapper } from './dto/company-info-dto.mapper';
import { CompanyInfoDto } from './dto/company-info.dto';
import { CompanyInfoService } from './company-info.service';

@ApiTags('CompanyInfo')
@Controller('company-info')
export class CompanyInfoController {
constructor(private readonly service: CompanyInfoService) {}

@Get(':brand')
@ApiOkResponse({ type: CompanyInfoDto })
async getForBrand(@Param('brand') brand: string): Promise<CompanyInfoDto> {
const info = await this.service.getForBrand(brand);
return CompanyInfoDtoMapper.entityToDto(info);
}
}
38 changes: 38 additions & 0 deletions src/shared/models/company-info/company-info.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Column, Entity, Index } from 'typeorm';
import { IEntity } from '../entity';

@Entity()
@Index(['brand'], { unique: true, where: '"enabled" = true' })
export class CompanyInfo extends IEntity {
// Brand identifier — `RealUnit`, `DFX`, etc. Lets a single backend serve
// multiple branded apps from one endpoint.
@Column({ length: 64 })
brand: string;

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

@Column({ length: 64, nullable: true })
phone?: string;

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

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

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

@Column({ length: 64, nullable: true })
addressZip?: string;

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

@Column({ length: 8, nullable: true })
addressCountry?: string; // ISO 3166-1 alpha-2

@Column({ default: true })
enabled: boolean;
}
11 changes: 11 additions & 0 deletions src/shared/models/company-info/company-info.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { CachedRepository } from 'src/shared/repositories/cached.repository';
import { EntityManager } from 'typeorm';
import { CompanyInfo } from './company-info.entity';

@Injectable()
export class CompanyInfoRepository extends CachedRepository<CompanyInfo> {
constructor(manager: EntityManager) {
super(CompanyInfo, manager);
}
}
55 changes: 55 additions & 0 deletions src/shared/models/company-info/company-info.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createMock } from '@golevelup/ts-jest';
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { ILike } from 'typeorm';
import { CompanyInfo } from './company-info.entity';
import { CompanyInfoRepository } from './company-info.repository';
import { CompanyInfoService } from './company-info.service';

const buildInfo = (overrides: Partial<CompanyInfo> = {}): CompanyInfo =>
Object.assign(new CompanyInfo(), {
id: 1,
brand: 'RealUnit',
name: 'RealUnit Schweiz AG',
phone: '+41 41 761 00 90',
email: 'info@realunit.ch',
website: 'realunit.ch',
addressStreet: 'Schochenmühlestrasse 6',
addressZip: '6340',
addressCity: 'Baar',
addressCountry: 'CH',
enabled: true,
...overrides,
});

describe('CompanyInfoService', () => {
let service: CompanyInfoService;
let repo: jest.Mocked<CompanyInfoRepository>;

beforeEach(async () => {
repo = createMock<CompanyInfoRepository>();
const module: TestingModule = await Test.createTestingModule({
providers: [CompanyInfoService, { provide: CompanyInfoRepository, useValue: repo }],
}).compile();
service = module.get<CompanyInfoService>(CompanyInfoService);
});

it('returns the enabled CompanyInfo for the requested brand', async () => {
const info = buildInfo();
repo.findOneCachedBy.mockResolvedValueOnce(info);

const result = await service.getForBrand('RealUnit');

expect(result).toBe(info);
expect(repo.findOneCachedBy).toHaveBeenCalledWith('brand:realunit', {
brand: ILike('RealUnit'),
enabled: true,
});
});

it('throws NotFoundException when the brand is unknown', async () => {
repo.findOneCachedBy.mockResolvedValueOnce(undefined);

await expect(service.getForBrand('Unknown')).rejects.toThrow(NotFoundException);
});
});
18 changes: 18 additions & 0 deletions src/shared/models/company-info/company-info.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ILike } from 'typeorm';
import { CompanyInfo } from './company-info.entity';
import { CompanyInfoRepository } from './company-info.repository';

@Injectable()
export class CompanyInfoService {
constructor(private readonly repo: CompanyInfoRepository) {}

async getForBrand(brand: string): Promise<CompanyInfo> {
const info = await this.repo.findOneCachedBy(`brand:${brand.toLowerCase()}`, {
brand: ILike(brand),
enabled: true,
});
if (!info) throw new NotFoundException(`CompanyInfo for brand "${brand}" not found`);
return info;
}
}
28 changes: 28 additions & 0 deletions src/shared/models/company-info/dto/company-info-dto.mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { CompanyInfo } from '../company-info.entity';
import { CompanyInfoAddressDto, CompanyInfoDto } from './company-info.dto';

export class CompanyInfoDtoMapper {
static entityToDto(info: CompanyInfo): CompanyInfoDto {
const hasAnyAddressField = info.addressStreet || info.addressZip || info.addressCity || info.addressCountry;

const address: CompanyInfoAddressDto | undefined = hasAnyAddressField
? {
street: info.addressStreet ?? undefined,
zip: info.addressZip ?? undefined,
city: info.addressCity ?? undefined,
country: info.addressCountry ?? undefined,
}
: undefined;

const dto: CompanyInfoDto = {
brand: info.brand,
name: info.name,
phone: info.phone ?? undefined,
email: info.email ?? undefined,
website: info.website ?? undefined,
address,
};

return Object.assign(new CompanyInfoDto(), dto);
}
}
35 changes: 35 additions & 0 deletions src/shared/models/company-info/dto/company-info.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CompanyInfoAddressDto {
@ApiPropertyOptional()
street?: string;

@ApiPropertyOptional()
zip?: string;

@ApiPropertyOptional()
city?: string;

@ApiPropertyOptional({ description: 'ISO 3166-1 alpha-2 country code' })
country?: string;
}

export class CompanyInfoDto {
@ApiProperty()
brand: string;

@ApiProperty()
name: string;

@ApiPropertyOptional()
phone?: string;

@ApiPropertyOptional()
email?: string;

@ApiPropertyOptional()
website?: string;

@ApiPropertyOptional({ type: CompanyInfoAddressDto })
address?: CompanyInfoAddressDto;
}
7 changes: 7 additions & 0 deletions src/shared/models/country/country.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export class Country extends IEntity {
@Column({ default: false })
manualReviewRequiredOrganization: boolean;

// Lower number = higher in country pickers. Default 999 keeps everything
// sorted alphabetically as before unless an admin promotes a country.
// The realunit-app's hardcoded priorityCountries (`['CH','DE','IT','FR']`)
// is seeded as 1..4 by the W4.3 migration.
@Column({ default: 999 })
displayOrder: number;

@Column({ type: 'text', nullable: true })
enabledKycDocuments: string; // semicolon separated KycDocuments

Expand Down
72 changes: 72 additions & 0 deletions src/shared/models/country/country.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createMock } from '@golevelup/ts-jest';
import { Test, TestingModule } from '@nestjs/testing';
import { Country } from './country.entity';
import { CountryRepository } from './country.repository';
import { CountryService } from './country.service';

const buildCountry = (overrides: Partial<Country> = {}): Country =>
Object.assign(new Country(), {
id: 1,
symbol: 'XX',
symbol3: 'XXX',
name: 'Generic',
foreignName: 'Generic',
dfxEnable: true,
dfxOrganizationEnable: true,
lockEnable: true,
ipEnable: true,
yapealEnable: false,
fatfEnable: true,
nationalityEnable: true,
nationalityStepEnable: true,
bankTransactionVerificationEnable: false,
bankEnable: true,
cryptoEnable: true,
checkoutEnable: true,
manualReviewRequired: false,
manualReviewRequiredOrganization: false,
displayOrder: 999,
...overrides,
});

describe('CountryService.getAllCountry', () => {
let service: CountryService;
let repo: jest.Mocked<CountryRepository>;

beforeEach(async () => {
repo = createMock<CountryRepository>();
const module: TestingModule = await Test.createTestingModule({
providers: [CountryService, { provide: CountryRepository, useValue: repo }],
}).compile();
service = module.get<CountryService>(CountryService);
});

it('sorts by displayOrder ascending and uses name as the tiebreaker', async () => {
repo.findCached.mockResolvedValueOnce([
buildCountry({ id: 4, symbol: 'FR', name: 'France', displayOrder: 4 }),
buildCountry({ id: 99, symbol: 'AT', name: 'Austria', displayOrder: 999 }),
buildCountry({ id: 1, symbol: 'CH', name: 'Switzerland', displayOrder: 1 }),
buildCountry({ id: 100, symbol: 'BE', name: 'Belgium', displayOrder: 999 }),
buildCountry({ id: 2, symbol: 'DE', name: 'Germany', displayOrder: 2 }),
buildCountry({ id: 3, symbol: 'IT', name: 'Italy', displayOrder: 3 }),
]);

const result = await service.getAllCountry();

// CH/DE/IT/FR come first (priority order), then alphabetic within 999.
expect(result.map((c) => c.symbol)).toEqual(['CH', 'DE', 'IT', 'FR', 'AT', 'BE']);
});

it('does not mutate the cached repository array', async () => {
const cached = [
buildCountry({ symbol: 'CH', name: 'Switzerland', displayOrder: 1 }),
buildCountry({ symbol: 'AT', name: 'Austria', displayOrder: 999 }),
];
repo.findCached.mockResolvedValueOnce(cached);

await service.getAllCountry();

// Original cached array preserved in insertion order.
expect(cached.map((c) => c.symbol)).toEqual(['CH', 'AT']);
});
});
Loading