diff --git a/src/lib/db/migrations/0096_interest_berth_price_override.sql b/src/lib/db/migrations/0096_interest_berth_price_override.sql new file mode 100644 index 00000000..b60189cb --- /dev/null +++ b/src/lib/db/migrations/0096_interest_berth_price_override.sql @@ -0,0 +1,7 @@ +-- CM-2 Part B: per-interest, per-berth deal-price override. +-- Null = use the berth's canonical list price (berths.price). When set, this +-- supersedes the list price for THIS interest's generated documents +-- (resolved in eoi-context via resolveBerthPriceForInterest). +ALTER TABLE interest_berths + ADD COLUMN IF NOT EXISTS price_override numeric, + ADD COLUMN IF NOT EXISTS price_override_currency text; diff --git a/src/lib/db/schema/interests.ts b/src/lib/db/schema/interests.ts index 80401fda..f00cda47 100644 --- a/src/lib/db/schema/interests.ts +++ b/src/lib/db/schema/interests.ts @@ -165,6 +165,10 @@ export const interestBerths = pgTable( addedBy: text('added_by'), addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(), notes: text('notes'), + // CM-2 Part B: deal-specific price for THIS (interest, berth). Null = use + // the berth's canonical list price. Does not touch berths.price. + priceOverride: numeric('price_override'), + priceOverrideCurrency: text('price_override_currency'), }, (table) => [ uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId), diff --git a/src/lib/services/interest-berths.service.ts b/src/lib/services/interest-berths.service.ts index 25434230..d0902568 100644 --- a/src/lib/services/interest-berths.service.ts +++ b/src/lib/services/interest-berths.service.ts @@ -170,6 +170,8 @@ export async function listBerthsForInterest( addedBy: interestBerths.addedBy, addedAt: interestBerths.addedAt, notes: interestBerths.notes, + priceOverride: interestBerths.priceOverride, + priceOverrideCurrency: interestBerths.priceOverrideCurrency, mooringNumber: berths.mooringNumber, area: berths.area, status: berths.status, @@ -444,3 +446,49 @@ export async function removeInterestBerth( .delete(interestBerths) .where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId))); } + +// ─── Per-interest price override (CM-2 Part B) ─────────────────────────────── + +/** + * Resolve the effective price for a berth in the context of an interest. The + * deal-specific override (when set) supersedes the berth's canonical list + * price; the override carries its own currency, falling back to the base + * currency when null. Pure — safe to unit-test without a DB. + */ +export function resolveBerthPriceForInterest( + override: { priceOverride: string | null; priceOverrideCurrency: string | null }, + base: { price: string | null; priceCurrency: string }, +): { price: string | null; currency: string } { + if (override.priceOverride != null) { + return { + price: override.priceOverride, + currency: override.priceOverrideCurrency ?? base.priceCurrency, + }; + } + return { price: base.price, currency: base.priceCurrency }; +} + +/** + * Set (or clear, when `price` is null) the deal-specific price for one + * (interest, berth). Tenant-scoped: the interest must belong to `portId`. + * Does not touch `berths.price`. + */ +export async function setBerthPriceOverride( + interestId: string, + berthId: string, + price: number | null, + currency: string | null, + portId: string, +): Promise { + const interestRow = await db.query.interests.findFirst({ + where: and(eq(interests.id, interestId), eq(interests.portId, portId)), + }); + if (!interestRow) throw new NotFoundError('Interest'); + await db + .update(interestBerths) + .set({ + priceOverride: price == null ? null : String(price), + priceOverrideCurrency: price == null ? null : (currency ?? 'USD'), + }) + .where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId))); +} diff --git a/tests/unit/services/interest-berths-price.test.ts b/tests/unit/services/interest-berths-price.test.ts new file mode 100644 index 00000000..10499438 --- /dev/null +++ b/tests/unit/services/interest-berths-price.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { resolveBerthPriceForInterest } from '@/lib/services/interest-berths.service'; + +describe('resolveBerthPriceForInterest', () => { + it('uses the override when present', () => { + expect( + resolveBerthPriceForInterest( + { priceOverride: '1000000', priceOverrideCurrency: 'EUR' }, + { price: '3880800', priceCurrency: 'USD' }, + ), + ).toEqual({ price: '1000000', currency: 'EUR' }); + }); + + it('falls back to the base list price when no override', () => { + expect( + resolveBerthPriceForInterest( + { priceOverride: null, priceOverrideCurrency: null }, + { price: '3880800', priceCurrency: 'USD' }, + ), + ).toEqual({ price: '3880800', currency: 'USD' }); + }); + + it('uses the base currency when the override currency is null', () => { + expect( + resolveBerthPriceForInterest( + { priceOverride: '900000', priceOverrideCurrency: null }, + { price: '3880800', priceCurrency: 'USD' }, + ), + ).toEqual({ price: '900000', currency: 'USD' }); + }); +});