feat(interests): CM-2 Part B — interest_berths price override (data + resolver)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 10:40:17 +02:00
parent b3753b96a1
commit 039ef25fe5
4 changed files with 91 additions and 0 deletions

View File

@@ -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;

View File

@@ -165,6 +165,10 @@ export const interestBerths = pgTable(
addedBy: text('added_by'), addedBy: text('added_by'),
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(), addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
notes: text('notes'), 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) => [ (table) => [
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId), uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),

View File

@@ -170,6 +170,8 @@ export async function listBerthsForInterest(
addedBy: interestBerths.addedBy, addedBy: interestBerths.addedBy,
addedAt: interestBerths.addedAt, addedAt: interestBerths.addedAt,
notes: interestBerths.notes, notes: interestBerths.notes,
priceOverride: interestBerths.priceOverride,
priceOverrideCurrency: interestBerths.priceOverrideCurrency,
mooringNumber: berths.mooringNumber, mooringNumber: berths.mooringNumber,
area: berths.area, area: berths.area,
status: berths.status, status: berths.status,
@@ -444,3 +446,49 @@ export async function removeInterestBerth(
.delete(interestBerths) .delete(interestBerths)
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId))); .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<void> {
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)));
}

View File

@@ -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' });
});
});