diff --git a/.env.example b/.env.example index 3bd548b..0aaf4b2 100644 --- a/.env.example +++ b/.env.example @@ -162,7 +162,8 @@ AVIATIONSTACK_API_KEY= # optional — leave blank to use mock data # OTEL_SDK_DISABLED=false # ─── Server ──────────────────────────────────────────────────────────────────── -# Port the API server listens on +# Port the API server listens on. Must be an integer from 1 through 65535; +# startup fails immediately when the configured value is outside this range. PORT=3001 # Maximum listeners per policy status event emitter. Default: 1000. diff --git a/README.md b/README.md index 87d43e0..77bed26 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,11 @@ Supporting infrastructure: - **PrismaService** — PostgreSQL integration for policy and oracle data storage - **AuthModule** — Stellar wallet signature verification + JWT issuance - **LoggingInterceptor** — Request/response duration logging -- **ThrottleGuard** — IP-based rate limiting (60 req/min) +- **UserThrottlerGuard** — anonymous callers use per-IP rate limiting (60 req/min); + authenticated callers use isolated per-wallet buckets with a 5× higher quota + +Server configuration validates `PORT` during startup. It must be an integer in +the TCP port range `1`–`65535`; when omitted, the API uses port `3001`. - **Vault bootstrap** — optional HashiCorp Vault KV loading before Nest validates env vars - **OpenTelemetry** — optional distributed tracing when OTEL packages are installed/configured @@ -424,6 +428,14 @@ For reference, successful responses use the complementary envelope: Monetary values are returned as strings in 7-decimal fixed-point format matching Stellar asset precision (e.g. `"10.0000000"`). +### Premium-rate changes + +Each product premium-rate template has a monotonically increasing version. +Changing `premiumRate` creates the next version; edits to unrelated product +fields do not. A policy records both the rate and template version in effect at +purchase time. Later product updates affect new quotes and policies only—an +existing policy's premium terms are never changed retroactively. + ### BigInt serialization & API contract change (#671) All `BigInt` values (such as oracle readings, large policy IDs, and 64-bit timestamps or token amounts) are centrally serialized as decimal strings across all JSON responses, SSE streams, logging paths, and error envelopes. @@ -481,6 +493,11 @@ CONCURRENCY_TEST_DATABASE_URL=postgresql://.../parashield_test npx jest claims.c **Threshold evaluation (#712):** oracle readings are 7-decimal fixed point (e.g. `324000000` = 32.4) and product thresholds are `Decimal(20,7)`. Trigger comparisons are done on exact scaled `BigInt` values (`src/common/utils/decimal.ts`) rather than `parseFloat`, so the last decimal place is never lost and a reading exactly equal to the threshold never triggers (strict `LessThan` / greater-than). +**Claim evaluation isolation:** the policy and its latest oracle reading are +loaded inside one PostgreSQL `REPEATABLE READ` transaction. Updates committed +after evaluation begins are intentionally deferred to the next evaluation, so +a decision never combines an old policy with a new oracle-key state. + --- ## Project layout diff --git a/prisma/migrations/20261019090000_version_premium_rates/migration.sql b/prisma/migrations/20261019090000_version_premium_rates/migration.sql new file mode 100644 index 0000000..3dcd636 --- /dev/null +++ b/prisma/migrations/20261019090000_version_premium_rates/migration.sql @@ -0,0 +1,21 @@ +-- Version product premium-rate templates and preserve the exact template used +-- by every policy. Existing policies are backfilled from their current product. +ALTER TABLE "Product" +ADD COLUMN "premiumRateVersion" INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE "Policy" +ADD COLUMN "premiumRate" INTEGER, +ADD COLUMN "premiumRateVersion" INTEGER; + +UPDATE "Policy" AS policy +SET + "premiumRate" = product."premiumRate", + "premiumRateVersion" = product."premiumRateVersion" +FROM "Product" AS product +WHERE policy."productId" = product."id"; + +ALTER TABLE "Policy" +ALTER COLUMN "premiumRate" SET NOT NULL, +ALTER COLUMN "premiumRate" SET DEFAULT 0, +ALTER COLUMN "premiumRateVersion" SET NOT NULL, +ALTER COLUMN "premiumRateVersion" SET DEFAULT 1; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cc8fcf7..5aa6288 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,9 @@ model Policy { policyholder String coverageXlm Decimal @db.Decimal(20, 7) premiumPaid Decimal @db.Decimal(20, 7) + // Immutable snapshot of the product's premium template at purchase time. + premiumRate Int @default(0) + premiumRateVersion Int @default(1) oracleKey String startTime DateTime endTime DateTime @@ -45,6 +48,7 @@ model Product { coverageMin Decimal @db.Decimal(20, 7) coverageMax Decimal @db.Decimal(20, 7) premiumRate Int + premiumRateVersion Int @default(1) maxDuration Int status ProductStatus @default(ACTIVE) policies Policy[] diff --git a/src/app.module.ts b/src/app.module.ts index 8b81b4c..d798591 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,7 @@ import { WebhooksModule } from './common/webhooks/webhooks.module'; import { validateJwtSecret } from './auth/jwt-secret.validator'; import { validateAllSecrets } from './common/secrets/secret-validator'; import { OpenTelemetryShutdownHook } from './common/telemetry/opentelemetry'; +import { validatePortEnv } from './common/utils/config.utils'; /** * Validate loaded environment configuration at startup. @@ -29,6 +30,12 @@ function validateConfig(config: Record) { const errors: string[] = []; const logger = new Logger('ConfigValidation'); + try { + config['PORT'] = validatePortEnv(config['PORT']); + } catch (err) { + errors.push((err as Error).message); + } + // #343 — not a hard requirement (a localhost fallback is fine for local // dev), but the ThrottlerModule storage below falls back to it silently, // which is easy to miss in a real deployment that meant to point at a diff --git a/src/claims/claim-evaluation-snapshot.spec.ts b/src/claims/claim-evaluation-snapshot.spec.ts new file mode 100644 index 0000000..e4aeddf --- /dev/null +++ b/src/claims/claim-evaluation-snapshot.spec.ts @@ -0,0 +1,62 @@ +import { Prisma } from '@prisma/client'; +import { readClaimEvaluationSnapshot } from './claim-evaluation-snapshot'; + +describe('readClaimEvaluationSnapshot', () => { + it('reads policy and oracle state in one repeatable-read transaction', async () => { + const policy = { id: 'policy-1', oracleKey: 'rainfall:old' }; + const reading = { id: 'reading-1', key: 'rainfall:old', value: 10n }; + const transactionClient = { + policy: { findUnique: jest.fn().mockResolvedValue(policy) }, + oracleReading: { findFirst: jest.fn().mockResolvedValue(reading) }, + }; + const prisma = { + $transaction: jest.fn(async (callback) => callback(transactionClient)), + } as any; + + await expect(readClaimEvaluationSnapshot(prisma, policy.id)).resolves.toEqual({ + policy, + reading, + }); + expect(prisma.$transaction).toHaveBeenCalledWith( + expect.any(Function), + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); + expect(transactionClient.oracleReading.findFirst).toHaveBeenCalledWith({ + where: { key: 'rainfall:old' }, + orderBy: { submittedAt: 'desc' }, + }); + }); + + it('cannot mix a concurrent policy update with a newer oracle key', async () => { + const database = { + policy: { id: 'policy-1', oracleKey: 'rainfall:v1' }, + readings: { + 'rainfall:v1': { id: 'reading-v1', key: 'rainfall:v1' }, + 'rainfall:v2': { id: 'reading-v2', key: 'rainfall:v2' }, + } as Record, + }; + const prisma = { + $transaction: jest.fn(async (callback) => { + // Capture the database state as PostgreSQL does at REPEATABLE READ. + const snapshot = structuredClone(database); + const tx = { + policy: { + findUnique: jest.fn(async () => { + database.policy.oracleKey = 'rainfall:v2'; // concurrent commit + return snapshot.policy; + }), + }, + oracleReading: { + findFirst: jest.fn(async ({ where }) => snapshot.readings[where.key] ?? null), + }, + }; + return callback(tx); + }), + } as any; + + const result = await readClaimEvaluationSnapshot(prisma, 'policy-1'); + expect(result.policy?.oracleKey).toBe('rainfall:v1'); + expect(result.reading?.key).toBe('rainfall:v1'); + expect(database.policy.oracleKey).toBe('rainfall:v2'); + }); +}); diff --git a/src/claims/claim-evaluation-snapshot.ts b/src/claims/claim-evaluation-snapshot.ts new file mode 100644 index 0000000..86c8280 --- /dev/null +++ b/src/claims/claim-evaluation-snapshot.ts @@ -0,0 +1,25 @@ +import { Prisma, PrismaClient } from '@prisma/client'; + +/** + * Read every mutable input needed to begin claim evaluation from one + * PostgreSQL REPEATABLE READ snapshot. A product/oracle update committed while + * this callback is running cannot be mixed with the policy state read first. + */ +export async function readClaimEvaluationSnapshot( + prisma: PrismaClient, + policyId: string, +) { + return prisma.$transaction( + async (tx) => { + const policy = await tx.policy.findUnique({ where: { id: policyId } }); + if (!policy) return { policy: null, reading: null }; + + const reading = await tx.oracleReading.findFirst({ + where: { key: policy.oracleKey }, + orderBy: { submittedAt: 'desc' }, + }); + return { policy, reading }; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); +} diff --git a/src/claims/claims.service.ts b/src/claims/claims.service.ts index d4c5bac..2fbf2bf 100644 --- a/src/claims/claims.service.ts +++ b/src/claims/claims.service.ts @@ -13,6 +13,7 @@ import { ErrorCode } from '../common/errors/error-codes'; import { compareScaledToThreshold, parseDecimalScaled, parseFixedPointBigInt } from '../common/utils/decimal'; import { financialBatch, financialTransaction, isSerializationFailure } from '../prisma/financial-transaction'; import { PolicyStateCacheService, DerivedPolicyState } from './policy-state-cache.service'; +import { readClaimEvaluationSnapshot } from './claim-evaluation-snapshot'; export type ClaimResult = 'Paid' | 'Rejected' | 'Expired' | 'AlreadyClaimed' | 'AlreadyProcessed' | 'PolicyNotActive' | 'PendingFinalPeriod'; @@ -109,8 +110,9 @@ export class ClaimsService { async autoProcess(policyId: string, productsMap?: Map): Promise { this.logger.log(`auto_process policy: ${policyId}`); - // Fetch policy from DB to verify it exists and is active - const policy = await this.prisma.policy.findUnique({ where: { id: policyId } }); + // Policy and oracle input must come from the same database snapshot. At + // REPEATABLE READ, a concurrent update cannot produce a mixed decision. + const { policy, reading } = await readClaimEvaluationSnapshot(this.prisma, policyId); if (!policy) { this.logger.warn(`Policy ${policyId} not found in DB`); return 'PolicyNotActive'; @@ -206,8 +208,8 @@ export class ClaimsService { this.claimCountCache.delete(policy.policyholder); this.logger.log(`Claim record created: id=${claim.id} policyId=${policyId}`); - // Fetch latest oracle reading for this policy's oracle key - const reading = await this.oracleService.getLatestReading(policy.oracleKey); + // Use the oracle row captured with the policy above; do not issue a second, + // independently committed read here. if (!reading) { this.logger.warn(`No oracle reading for key=${policy.oracleKey} — rejecting claim ${claim.id}`); await financialBatch(this.prisma, `claim ${claim.id}`, () => [ diff --git a/src/common/guards/user-throttler.guard.spec.ts b/src/common/guards/user-throttler.guard.spec.ts new file mode 100644 index 0000000..54f99fe --- /dev/null +++ b/src/common/guards/user-throttler.guard.spec.ts @@ -0,0 +1,77 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { UserThrottlerGuard } from './user-throttler.guard'; + +class TestGuard extends UserThrottlerGuard { + public run(props: any) { + return this.handleRequest(props); + } +} + +function contextFor(request: Record): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => ({ setHeader: jest.fn() }), + }), + getClass: () => class TestController {}, + getHandler: () => function testHandler() {}, + } as unknown as ExecutionContext; +} + +describe('UserThrottlerGuard differentiated limits', () => { + const increment = jest.fn().mockResolvedValue({ + totalHits: 1, + timeToExpire: 60, + isBlocked: false, + timeToBlockExpire: 0, + }); + const jwtService = { verifyAsync: jest.fn() }; + const guard = new TestGuard( + [{ name: 'default', ttl: 60_000, limit: 60 }], + { increment } as any, + new Reflector(), + jwtService as any, + ); + + const requestProps = (request: Record) => ({ + context: contextFor(request), + limit: 60, + ttl: 60_000, + blockDuration: 60_000, + throttler: { name: 'default' }, + getTracker: jest.fn(), + generateKey: jest.fn((_context, tracker) => `rate:${tracker}`), + }); + + beforeAll(async () => guard.onModuleInit()); + + beforeEach(() => jest.clearAllMocks()); + + it('keeps anonymous callers in the lower per-IP bucket', async () => { + await guard.run(requestProps({ ip: '203.0.113.10', headers: {} })); + expect(increment).toHaveBeenCalledWith( + 'rate:203.0.113.10', 60_000, 60, 60_000, 'default', + ); + }); + + it('gives authenticated callers a higher per-user limit', async () => { + await guard.run(requestProps({ + ip: '203.0.113.10', + wallet: 'GALICE', + headers: {}, + })); + expect(increment).toHaveBeenCalledWith( + 'rate:user:GALICE', 60_000, 300, 60_000, 'default', + ); + }); + + it('uses independent buckets for authenticated users sharing an IP', async () => { + await guard.run(requestProps({ ip: '203.0.113.10', wallet: 'GALICE', headers: {} })); + await guard.run(requestProps({ ip: '203.0.113.10', wallet: 'GBOB', headers: {} })); + expect(increment.mock.calls.map((call) => call[0])).toEqual([ + 'rate:user:GALICE', + 'rate:user:GBOB', + ]); + }); +}); diff --git a/src/common/guards/user-throttler.guard.ts b/src/common/guards/user-throttler.guard.ts index 5a5202a..db6df82 100644 --- a/src/common/guards/user-throttler.guard.ts +++ b/src/common/guards/user-throttler.guard.ts @@ -6,6 +6,7 @@ import { ThrottlerStorage, InjectThrottlerOptions, InjectThrottlerStorage, + ThrottlerRequest, } from '@nestjs/throttler'; import { JwtService } from '../../auth/jwt.service'; @@ -30,6 +31,8 @@ import { JwtService } from '../../auth/jwt.service'; */ @Injectable() export class UserThrottlerGuard extends ThrottlerGuard { + private static readonly AUTHENTICATED_LIMIT_MULTIPLIER = 5; + constructor( @InjectThrottlerOptions() options: ThrottlerModuleOptions, @InjectThrottlerStorage() storageService: ThrottlerStorage, @@ -59,4 +62,22 @@ export class UserThrottlerGuard extends ThrottlerGuard { return super.getTracker(req); } + + /** + * Anonymous requests retain the configured IP quota. Authenticated requests + * receive a separate, per-wallet bucket with a five-times-higher quota. + */ + protected async handleRequest(requestProps: ThrottlerRequest): Promise { + const { req } = this.getRequestResponse(requestProps.context); + const tracker = await this.getTracker(req); + const authenticated = tracker.startsWith('user:'); + + return super.handleRequest({ + ...requestProps, + limit: authenticated + ? requestProps.limit * UserThrottlerGuard.AUTHENTICATED_LIMIT_MULTIPLIER + : requestProps.limit, + getTracker: async () => tracker, + }); + } } diff --git a/src/common/utils/config.utils.spec.ts b/src/common/utils/config.utils.spec.ts new file mode 100644 index 0000000..c0d3bb0 --- /dev/null +++ b/src/common/utils/config.utils.spec.ts @@ -0,0 +1,18 @@ +import { validatePortEnv } from './config.utils'; + +describe('validatePortEnv', () => { + it.each([1, 3001, 65_535, '8080'] as const)('accepts valid port %p', (value) => { + expect(validatePortEnv(value)).toBe(Number(value)); + }); + + it('uses the documented default when unset', () => { + expect(validatePortEnv(undefined)).toBe(3001); + }); + + it.each([0, -1, 65_536, '1.5', 'abc', '3000junk', Number.NaN, Number.POSITIVE_INFINITY])( + 'fails fast for invalid port %p', + (value) => { + expect(() => validatePortEnv(value)).toThrow('PORT must be an integer between 1 and 65535'); + }, + ); +}); diff --git a/src/common/utils/config.utils.ts b/src/common/utils/config.utils.ts index c7e89f6..e8688f7 100644 --- a/src/common/utils/config.utils.ts +++ b/src/common/utils/config.utils.ts @@ -73,3 +73,12 @@ export function validateOptionalUrlEnv(value: string | undefined, name: string): } return validateUrlEnv(value, name); } + +/** Validate a TCP port and return its numeric value. */ +export function validatePortEnv(value: unknown, name = 'PORT', defaultValue = 3001): number { + const candidate = value === undefined || value === null || value === '' ? defaultValue : Number(value); + if (!Number.isInteger(candidate) || candidate < 1 || candidate > 65_535) { + throw new Error(`${name} must be an integer between 1 and 65535`); + } + return candidate; +} diff --git a/src/main.ts b/src/main.ts index 9c34c83..71fd427 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,7 @@ import { json, urlencoded } from 'express'; import Redis from 'ioredis'; import { validateJwtSecret } from './auth/jwt-secret.validator'; import { validateAllSecrets } from './common/secrets/secret-validator'; +import { validatePortEnv } from './common/utils/config.utils'; const REQUEST_BODY_LIMIT = '1mb'; const SERVER_TIMEOUT_MS = 30_000; @@ -479,7 +480,7 @@ async function bootstrap() { app.enableShutdownHooks(); - const port = configService.get('PORT') ?? 3001; + const port = validatePortEnv(configService.get('PORT')); const server = await app.listen(port); server.timeout = SERVER_TIMEOUT_MS; logger.log(`Parashield API running on http://localhost:${port}/api/v1`); diff --git a/src/policy/dto/policy-response.dto.ts b/src/policy/dto/policy-response.dto.ts index e83cdcd..de0a93f 100644 --- a/src/policy/dto/policy-response.dto.ts +++ b/src/policy/dto/policy-response.dto.ts @@ -28,6 +28,9 @@ export class ProductResponseDto { @ApiProperty({ description: 'Premium rate in basis points (500 = 5%)' }) premiumRate: number; + @ApiProperty({ description: 'Version of the premium-rate template' }) + premiumRateVersion: number; + @ApiProperty({ description: 'Maximum policy duration in days' }) maxDuration: number; diff --git a/src/policy/policy.service.ts b/src/policy/policy.service.ts index ef8808a..3e34e67 100644 --- a/src/policy/policy.service.ts +++ b/src/policy/policy.service.ts @@ -11,6 +11,7 @@ import { CreateProductDto, UpdateProductDto } from './dto/admin-product.dto'; import { UpdatePolicyDto } from './dto/update-policy.dto'; import { WebhooksService } from '../common/events/webhooks.service'; import { StatusEventsService, PolicyOracleKeyChangeEvent } from '../common/events/status-events.service'; +import { nextPremiumRateVersion, snapshotPremiumRate } from './premium-rate-versioning'; const STROOPS_PER_XLM = 10_000_000n; @@ -33,6 +34,7 @@ export interface ProductSummary { coverageMin: string; coverageMax: string; premiumRate: number; + premiumRateVersion?: number; maxDuration: number; status: string; } @@ -367,6 +369,10 @@ export class PolicyService { } const premiumPaid = this.calculatePremium(dto.coverageXlm, product.premiumRate, dto.duration); + const rateSnapshot = snapshotPremiumRate({ + premiumRate: product.premiumRate, + premiumRateVersion: product.premiumRateVersion ?? 1, + }); try { const policy = await this.prisma.policy.create({ @@ -375,6 +381,7 @@ export class PolicyService { policyholder: dto.walletAddress, coverageXlm: dto.coverageXlm, premiumPaid, + ...rateSnapshot, oracleKey: dto.oracleKey, startTime: now, endTime, @@ -601,6 +608,7 @@ export class PolicyService { coverageMin: product.coverageMin.toString(), coverageMax: product.coverageMax.toString(), premiumRate: product.premiumRate, + premiumRateVersion: product.premiumRateVersion, maxDuration: product.maxDuration, status: product.status, }; @@ -634,7 +642,8 @@ export class PolicyService { select: { id: true, name: true, category: true, triggerType: true, threshold: true, comparison: true, coverageMin: true, - coverageMax: true, premiumRate: true, maxDuration: true, status: true, + coverageMax: true, premiumRate: true, premiumRateVersion: true, + maxDuration: true, status: true, }, take, skip, @@ -653,6 +662,7 @@ export class PolicyService { coverageMin: product.coverageMin.toString(), coverageMax: product.coverageMax.toString(), premiumRate: product.premiumRate, + premiumRateVersion: product.premiumRateVersion, maxDuration: product.maxDuration, status: product.status, })); @@ -1151,6 +1161,7 @@ export class PolicyService { coverageMin: dto.coverageMin, coverageMax: dto.coverageMax, premiumRate: dto.premiumRate, + premiumRateVersion: 1, maxDuration: dto.maxDuration, status: (dto.status as ProductStatus) ?? ProductStatus.ACTIVE, }, @@ -1176,7 +1187,10 @@ export class PolicyService { ...(dto.comparison !== undefined ? { comparison: dto.comparison } : {}), ...(dto.coverageMin !== undefined ? { coverageMin: dto.coverageMin } : {}), ...(dto.coverageMax !== undefined ? { coverageMax: dto.coverageMax } : {}), - ...(dto.premiumRate !== undefined ? { premiumRate: dto.premiumRate } : {}), + ...(dto.premiumRate !== undefined ? { + premiumRate: dto.premiumRate, + premiumRateVersion: nextPremiumRateVersion(existing, dto.premiumRate), + } : {}), ...(dto.maxDuration !== undefined ? { maxDuration: dto.maxDuration } : {}), ...(dto.status !== undefined ? { status: dto.status as ProductStatus } : {}), }, @@ -1235,6 +1249,7 @@ export class PolicyService { coverageMin: product.coverageMin.toString(), coverageMax: product.coverageMax.toString(), premiumRate: product.premiumRate, + premiumRateVersion: product.premiumRateVersion, maxDuration: product.maxDuration, status: product.status, }; diff --git a/src/policy/premium-rate-versioning.spec.ts b/src/policy/premium-rate-versioning.spec.ts new file mode 100644 index 0000000..42bc3f3 --- /dev/null +++ b/src/policy/premium-rate-versioning.spec.ts @@ -0,0 +1,21 @@ +import { nextPremiumRateVersion, snapshotPremiumRate } from './premium-rate-versioning'; + +describe('premium rate versioning', () => { + it('increments the template version only when the rate changes', () => { + const current = { premiumRate: 500, premiumRateVersion: 3 }; + expect(nextPremiumRateVersion(current, 650)).toBe(4); + expect(nextPremiumRateVersion(current, 500)).toBe(3); + expect(nextPremiumRateVersion(current, undefined)).toBe(3); + }); + + it('keeps a policy snapshot unchanged after a template update', () => { + const product = { premiumRate: 500, premiumRateVersion: 1 }; + const policyRate = snapshotPremiumRate(product); + + product.premiumRate = 750; + product.premiumRateVersion = nextPremiumRateVersion(policyRate, product.premiumRate); + + expect(policyRate).toEqual({ premiumRate: 500, premiumRateVersion: 1 }); + expect(product).toEqual({ premiumRate: 750, premiumRateVersion: 2 }); + }); +}); diff --git a/src/policy/premium-rate-versioning.ts b/src/policy/premium-rate-versioning.ts new file mode 100644 index 0000000..bc82b90 --- /dev/null +++ b/src/policy/premium-rate-versioning.ts @@ -0,0 +1,22 @@ +export interface PremiumRateTemplate { + premiumRate: number; + premiumRateVersion: number; +} + +/** A template version changes only when its premium rate changes. */ +export function nextPremiumRateVersion( + current: PremiumRateTemplate, + nextRate: number | undefined, +): number { + return nextRate !== undefined && nextRate !== current.premiumRate + ? current.premiumRateVersion + 1 + : current.premiumRateVersion; +} + +/** Copy the rate fields so a policy never follows later product mutations. */ +export function snapshotPremiumRate(template: PremiumRateTemplate): PremiumRateTemplate { + return { + premiumRate: template.premiumRate, + premiumRateVersion: template.premiumRateVersion, + }; +}