Class: PricingMatrixService
Defined in: server/pricing/pricing-matrix-service.ts:222
Orchestration layer for the pricing tier x schedule matrix (PLA-136).
Owns the full cell lifecycle (add/update/delete tiers and schedules,
set/unset cell prices) and is the ONLY code path permitted to construct
matrix-managed Rules/Conditions rows — RuleService.create/
createVersion and ConditionService.create/createVersion/
softDelete all guard against direct construction of these rows (see
MatrixManagedResourceError) and this service bypasses those guards
everywhere, including unsetCellPrice, by writing through its own
internal repository access (this.repos) rather than calling through
the guarded service methods. unsetCellPrice used to call through the
guarded, generic RuleService.softDelete instead (soft-deleting a cell
rule never touches rateTierId/scheduleConditionId, so there was
nothing for that guard to catch) — but that path opens its own
transaction independent of this class's row locks, which defeated the
locking added to close a revive/price-update race; it now stays inside
this.repos.transaction(...) like every other mutating method, holding
the same tier lock (see unsetCellPrice's own doc).
Constructors
Constructor
new PricingMatrixService(options: PricingMatrixServiceOptions): PricingMatrixService;Defined in: server/pricing/pricing-matrix-service.ts:226
Parameters
| Parameter | Type |
|---|---|
options | PricingMatrixServiceOptions |
Returns
PricingMatrixService
Methods
addRateTier()
addRateTier(input: AddRateTierInput): Promise<{
createdAt: Date;
createdBy: string | null;
defaultAmountCents: number | null;
defaultPer: string | null;
defaultUnit: string | null;
deletedAt: Date | null;
deletedBy: string | null;
description: string | null;
id: string;
name: string;
organizationId: string;
scopes: JsonValue;
updatedAt: Date;
updatedBy: string | null;
}>;Defined in: server/pricing/pricing-matrix-service.ts:241
Create a new rate tier ("row" of the matrix). Rejects any
caller-supplied rate_tier_id scope entry in input.scopes and
always injects exactly one canonical entry using the newly-generated
tier's own id — this is what makes the exclusivity mechanism work
(see the class doc). No Rules rows are created here.
Parameters
| Parameter | Type |
|---|---|
input | AddRateTierInput |
Returns
Promise<{
createdAt: Date;
createdBy: string | null;
defaultAmountCents: number | null;
defaultPer: string | null;
defaultUnit: string | null;
deletedAt: Date | null;
deletedBy: string | null;
description: string | null;
id: string;
name: string;
organizationId: string;
scopes: JsonValue;
updatedAt: Date;
updatedBy: string | null;
}>
addSchedule()
addSchedule(input: AddScheduleInput): Promise<ConditionRecordWithVersion>;Defined in: server/pricing/pricing-matrix-service.ts:413
Validates the schedule config (Zod refinements: non-wrapping
time_range, degenerate day_of_week, holidays-requires-date_range,
not-fully-wildcard), the start_field/end_field/zone consistency rule
against the org's existing active schedules, acquires the namespaced
advisory lock, runs the exact overlap check against every existing
active schedule for the org+domain, and creates the PRICING_SCHEDULE
Conditions row (bypassing ConditionService's guard via direct
repository access). No Rules rows are created here.
Parameters
| Parameter | Type |
|---|---|
input | AddScheduleInput |
Returns
Promise<ConditionRecordWithVersion>
deleteRateTier()
deleteRateTier(rateTierId: string): Promise<{
createdAt: Date;
createdBy: string | null;
defaultAmountCents: number | null;
defaultPer: string | null;
defaultUnit: string | null;
deletedAt: Date | null;
deletedBy: string | null;
description: string | null;
id: string;
name: string;
organizationId: string;
scopes: JsonValue;
updatedAt: Date;
updatedBy: string | null;
}>;Defined in: server/pricing/pricing-matrix-service.ts:382
Soft-deletes the tier and cascades to soft-delete every cell rule
under it, clears PricingRateTierRequirements, and nulls capacity
settings references to mirror the FK's hard-delete behavior.
Without clearing the join rows, ConditionService.softDelete would
keep rejecting those eligibility conditions forever — its guard counts
join rows by conditionId alone, and updateRateTierRequirements can
no longer detach them once lockById returns null for the soft-deleted
tier. Row-locks the tier first (same lockById used by
setCellPrice/updateRateTierRequirements) so a concurrent
setCellPrice for this tier can't commit a brand-new live cell in the
window between this method's cell scan and its tiers.softDelete —
without the lock, such a cell would be permanently orphaned under a
now-soft-deleted tier.
Parameters
| Parameter | Type |
|---|---|
rateTierId | string |
Returns
Promise<{
createdAt: Date;
createdBy: string | null;
defaultAmountCents: number | null;
defaultPer: string | null;
defaultUnit: string | null;
deletedAt: Date | null;
deletedBy: string | null;
description: string | null;
id: string;
name: string;
organizationId: string;
scopes: JsonValue;
updatedAt: Date;
updatedBy: string | null;
}>
deleteSchedule()
deleteSchedule(conditionId: string): Promise<ConditionRecord>;Defined in: server/pricing/pricing-matrix-service.ts:619
Soft-deletes the schedule and cascades to soft-delete every cell rule
referencing it. Row-locks the schedule's Conditions row (via
conditionsCrud.lockById) before scanning for live cells —
setCellPrice takes the SAME lock before creating/reviving a cell
against a schedule, so the two serialize instead of racing: without
this lock, a concurrent setCellPrice for ANY tier against this
schedule could commit a brand-new live cell in the window between this
method's cell scan and the schedule's soft-delete, permanently
orphaning that cell under a now-deleted schedule.
Parameters
| Parameter | Type |
|---|---|
conditionId | string |
Returns
Promise<ConditionRecord>
setCellPrice()
setCellPrice(input: {
amountCents: number;
per?: "day" | "hour" | "night" | null;
rateTierId: string;
scheduleConditionId: string;
unit: "flat" | "unit" | "year" | "month" | "day" | "hour" | "night" | "person";
}): Promise<RuleRecordWithVersion>;Defined in: server/pricing/pricing-matrix-service.ts:662
Row-locks the tier, then finds-or-creates-or-revives the cell rule for (rateTierId, scheduleConditionId) — never a naive upsert, since the partial unique index isn't usable as a compound upsert selector:
- Found + soft-deleted → revive (clear deletedAt/deletedBy), new RuleVersion.
- Found + live → new RuleVersion (updates the price).
- Not found → create fresh with the deterministic identifier
pricing-matrix-cell-${rateTierId}-${scheduleConditionId}.
Constructs domain_config itself (exactly one BASE_PRICE action,
level: "line", calculation_phase: "base") — never accepts one from
the caller. condition_logic is an AND node combining the schedule
condition with every one of the tier's CURRENT requirement condition
ids, read fresh from PricingRateTierRequirements inside this same
locked transaction (not passed in stale).
Still constructs the whole domain_config: per (PLA-233 #2) is one
more scalar input alongside amountCents/unit, not a caller-supplied
config fragment. An OMITTED per preserves the cell's existing axis
rather than dropping it — see SetCellPriceInputSchema.per and
writeCellPrice.
Parameters
| Parameter | Type | Description |
|---|---|---|
input | { amountCents: number; per?: "day" | "hour" | "night" | null; rateTierId: string; scheduleConditionId: string; unit: "flat" | "unit" | "year" | "month" | "day" | "hour" | "night" | "person"; } | - |
input.amountCents | number | - |
input.per? | "day" | "hour" | "night" | null | The optional SECOND pricing axis — the tier modal's "Duration" select (PLA-233 #2): unit: "person", per: "hour" is "per attendee per hour". Rides inside the cell rule's existing domain_config JSON, so no PricingRateTiers column and no migration. The per-with-a-count-unit invariant is NOT restated here: this schema deliberately validates only the shape, and PricingDomainConfig.parse in writeCellPrice — the single owner of that rule (BasePriceAction) — rejects e.g. flat + per before anything is written. One source of truth, at the cost of the ZodError naming actions.0.per rather than per. undefined and null mean different things, the same split updateRateTier's defaultPrice uses: - omitted / undefined → leave the cell's existing axis alone. The grid's quick-edit path commits an amount and a unit and knows nothing else about the cell, so treating an omitted per as "no second axis" made an ordinary inline amount tweak silently downgrade "$10/attendee/hour" to "$10/attendee" — a price DECREASE with no error and no audit trail. - null → remove the axis. Explicit, greppable, and the only way to lose one, mirroring how clearing a cell is unsetCellPrice rather than setCellPrice({ amountCents: 0 }). |
input.rateTierId | string | - |
input.scheduleConditionId | string | - |
input.unit | "flat" | "unit" | "year" | "month" | "day" | "hour" | "night" | "person" | - |
Returns
Promise<RuleRecordWithVersion>
unsetCellPrice()
unsetCellPrice(rateTierId: string, scheduleConditionId: string): Promise<void>;Defined in: server/pricing/pricing-matrix-service.ts:823
Row-locks the tier, then finds the live cell rule and soft-deletes it.
Parameters
| Parameter | Type |
|---|---|
rateTierId | string |
scheduleConditionId | string |
Returns
Promise<void>
updateRateTier()
updateRateTier(rateTierId: string, patch: {
defaultPrice?: | {
amountCents: number;
per?: "day" | "hour" | "night" | null;
unit: "flat" | "unit" | "year" | "month" | "day" | "hour" | "night" | "person";
}
| null;
description?: string | null;
name?: string;
}): Promise<{
createdAt: Date;
createdBy: string | null;
defaultAmountCents: number | null;
defaultPer: string | null;
defaultUnit: string | null;
deletedAt: Date | null;
deletedBy: string | null;
description: string | null;
id: string;
name: string;
organizationId: string;
scopes: JsonValue;
updatedAt: Date;
updatedBy: string | null;
}>;Defined in: server/pricing/pricing-matrix-service.ts:292
Simple parent-row update (name/description only) — no cascade.
Parameters
| Parameter | Type | Description |
|---|---|---|
rateTierId | string | - |
patch | { defaultPrice?: | { amountCents: number; per?: "day" | "hour" | "night" | null; unit: "flat" | "unit" | "year" | "month" | "day" | "hour" | "night" | "person"; } | null; description?: string | null; name?: string; } | - |
patch.defaultPrice? | | { amountCents: number; per?: "day" | "hour" | "night" | null; unit: "flat" | "unit" | "year" | "month" | "day" | "hour" | "night" | "person"; } | null | PLA-141. Omit to leave the default alone; pass null to clear it (which is what the grid's "All open hours" column sends when its cell is emptied — distinct from amountCents: 0, which means free). Setting it changes the effective price for lines that match no schedule column, but deliberately does NOT rewrite existing cells — those hold deliberate per-cell prices and silently overwriting them would destroy work. It only ever seeds cells that don't exist yet, which in practice means a schedule column added after this point. |
patch.description? | string | null | - |
patch.name? | string | - |
Returns
Promise<{
createdAt: Date;
createdBy: string | null;
defaultAmountCents: number | null;
defaultPer: string | null;
defaultUnit: string | null;
deletedAt: Date | null;
deletedBy: string | null;
description: string | null;
id: string;
name: string;
organizationId: string;
scopes: JsonValue;
updatedAt: Date;
updatedBy: string | null;
}>
updateRateTierRequirements()
updateRateTierRequirements(rateTierId: string, newRequirementConditionIds: string[]): Promise<void>;Defined in: server/pricing/pricing-matrix-service.ts:331
Under a tier lock, replaces its requirements and rebuilds every live cell's
condition_logic in one transaction.
Parameters
| Parameter | Type |
|---|---|
rateTierId | string |
newRequirementConditionIds | string[] |
Returns
Promise<void>
updateSchedule()
updateSchedule(conditionId: string, input: UpdateScheduleInput): Promise<ConditionRecordWithVersion>;Defined in: server/pricing/pricing-matrix-service.ts:544
Same validation + advisory lock + overlap check as addSchedule
(excluding this schedule itself from the comparison set), then a new
ConditionVersion.
Parameters
| Parameter | Type |
|---|---|
conditionId | string |
input | UpdateScheduleInput |
Returns
Promise<ConditionRecordWithVersion>