Skip to main content

API_CONTRACTS — pricing-service

Sibling: APPLICATION_LOGIC · EVENT_SCHEMAS · SECURITY_MODEL

Strategic anchors: 05 API Design · Standards / NAMING · Standards / ERROR_CODES

The pricing-service exposes a versioned HTTP/JSON public REST surface plus an internal admin surface and a private inter-service surface used by reservation-service. All external traffic is mediated by the API Gateway as defined in 05 §3.


1. Conventions

ConcernRule
Base path/v1
Public hostapi.melmastoon.tech
Admin hostadmin-api.melmastoon.tech (internal)
AuthOAuth2 access token (Authorization: Bearer <jwt>)
TenancyX-Tenant-Id MUST match tenant_id claim in JWT
IdempotencyIdempotency-Key REQUIRED on POST/PATCH/DELETE that mutate state
Tracingtraceparent/tracestate per W3C Trace Context
PaginationCursor-based: ?limit=, ?cursor=; response carries nextCursor
Money on the wireStrings as "<microUnits>:<currency>" (e.g. "125000000:USD" = 125.00 USD)
ErrorsRFC 7807 application/problem+json per ERROR_CODES
VersioningURL major (/v1); breaking changes ship as /v2 per 05 §10
OpenAPIservices/pricing-service/openapi/pricing-v1.yaml (generated; do not hand-edit)

Money wire format detail: lossless across JS, Postgres numeric, Java BigDecimal, and Go big.Int. Display currency conversion is applied server-side; never client-side.


2. Public endpoints (api.melmastoon.tech)

2.1 POST /v1/pricing/quotes — Calculate a price quote

Used by the reservation flow to derive a transactionally-pinned price for a stay window. Returns a short-lived PriceQuote (TTL ~30 minutes).

POST /v1/pricing/quotes HTTP/2
Host: api.melmastoon.tech
Authorization: Bearer eyJhbGciOi…
X-Tenant-Id: tnt_01H8Z4M1QCV5RNSP2X4N6BYYGE
Idempotency-Key: 8e7c1c6c-9e9f-4cda-9e2e-1f5d0d8f3a14
Content-Type: application/json

{
"propertyId": "pty_01H8Z4M2QY8NQ4M5RX9V0Y6T9P",
"ratePlanCode": "BAR",
"stayWindow": { "start": "2026-05-12", "end": "2026-05-15" },
"roomTypeIds": ["rmt_01H8Z4M3PVHX9G7E0XV1KH9N6Q"],
"occupancy": { "adults": 2, "children": 0 },
"displayCurrency": "USD",
"promoCode": "SUMMER10",
"channel": "direct",
"bookerLocale": "en-US"
}

Successful response — 200 OK:

{
"id": "qte_01H8Z4MABCDEFGHJKMNPQRSTVW",
"tenantId": "tnt_01H8Z4M1QCV5RNSP2X4N6BYYGE",
"propertyId": "pty_01H8Z4M2QY8NQ4M5RX9V0Y6T9P",
"status": "live",
"requestedAt": "2026-04-22T10:14:09Z",
"expiresAt": "2026-04-22T10:44:09Z",
"ttlSeconds": 1800,
"ratePlan": {
"id": "rate_01H8Z4M0CNJW9N7QM4D3K6BVAN",
"version": 14,
"snapshotName": "BAR"
},
"fxSnapshot": {
"id": "fxs_01H8Z4M5XYZ123456789ABCDE",
"base": "AFN",
"quote": "USD",
"rate": 0.014,
"capturedAt": "2026-04-22T06:00:00Z",
"stale": false
},
"promoApplied": { "id": "prm_01H8Z4M7…", "code": "SUMMER10", "redemptionId": "01H8Z4M9…" },
"totals": {
"currency": "USD",
"displayCurrency": "USD",
"nightCount": 3,
"subtotalMicro": "375000000:USD",
"discountMicro": "37500000:USD",
"feesMicro": "15000000:USD",
"taxesMicro": "30000000:USD",
"grandTotalMicro": "382500000:USD"
},
"derivation": {
"shariaGuardPasses": true,
"steps": [
{ "step": "ResolveRatePlan", "outcome": { "ratePlanId": "rate_01H8Z4M0CNJW9N7QM4D3K6BVAN" } },
{ "step": "DeriveNightlyBase", "outcome": { "perNight": ["125000000:USD","125000000:USD","125000000:USD"] } },
{ "step": "ApplyDiscounts", "outcome": { "discountMicro": "37500000:USD" } },
{ "step": "ComposeFees", "outcome": { "feesMicro": "15000000:USD" } },
{ "step": "ComposeTaxes", "outcome": { "taxesMicro": "30000000:USD" } },
{ "step": "ApplyFx", "outcome": { "fxSnapshotId": "fxs_01H8Z4M5…" } },
{ "step": "ShariaGuard", "outcome": { "passes": true } },
{ "step": "PinQuote", "outcome": { "expiresAt": "2026-04-22T10:44:09Z" } }
]
}
}

Error responses:

HTTPtype (URN)title
400urn:problem:melmastoon.general.validation_failedinvalid request body
404urn:problem:melmastoon.pricing.rate_plan_not_foundno candidate rate plan matched
409urn:problem:melmastoon.pricing.fx_snapshot_staleFX snapshot past hard expiry; retry after melmastoon.pricing.fx_snapshot.updated.v1
409urn:problem:melmastoon.pricing.sharia_guard_failedSharia plan attempted with riba_forbidden fee
409urn:problem:melmastoon.pricing.promo_overobligationpromo cap reached
422urn:problem:melmastoon.pricing.derivation_failedrule selection produced empty rate set

2.2 GET /v1/pricing/quotes/{quoteId}

GET /v1/pricing/quotes/qte_01H8Z4MABCDEFGHJKMNPQRSTVW
Authorization: Bearer …
X-Tenant-Id: tnt_…

200 OK returns the same envelope as POST /quotes. If the quote is past expiresAt, the service lazily transitions it to expired, emits melmastoon.pricing.quote.expired.v1, and returns status: "expired". After hard TTL (24h) the resource returns 404.

2.3 POST /v1/pricing/quotes/{quoteId}/refresh

Idempotent re-derivation while preserving the quoteId for client UX (avoids re-rendering). Caller MUST send the same Idempotency-Key. Internally creates a new derivation, but rebinds it to the original id.

409 urn:problem:melmastoon.pricing.quote_expired if hard-expired.

2.4 POST /v1/pricing/promotions/validate

Pre-validates a promo code from the booking page before the user commits.

{ "propertyId": "pty_…", "ratePlanCode": "BAR", "code": "SUMMER10",
"stayWindow": { "start": "2026-05-12", "end": "2026-05-15" } }

200 OK:

{ "valid": true, "promo": { "code": "SUMMER10", "discountPct": 10.0, "currency": "USD" } }

or { "valid": false, "reason": "cap_reached" }.

2.5 GET /v1/pricing/rate-plans?propertyId=…&channel=direct&active=true

Lists active, channel-applicable rate plans for shop-on-page-load. Cursor-paginated. Returns lean projections (no rules, no fees).

2.6 GET /v1/pricing/rate-plans/{ratePlanId}

Full read of a single plan, including embedded rules, roomTypes, discounts, computed effectiveStatus, current version. ETag included for OCC on subsequent edits.


3. Admin endpoints (admin-api.melmastoon.tech)

Every admin endpoint requires roles per the APPLICATION_LOGIC §6 RBAC matrix.

3.1 Rate plans

MethodPathUse case
POST/v1/admin/pricing/rate-plansCreateRatePlanUseCase
GET/v1/admin/pricing/rate-planslist (filter: status, channelScope, propertyId)
GET/v1/admin/pricing/rate-plans/{id}read
PATCH/v1/admin/pricing/rate-plans/{id}UpdateRatePlanUseCase (OCC via If-Match: <version>)
POST/v1/admin/pricing/rate-plans/{id}:publishPublishRatePlanUseCase
POST/v1/admin/pricing/rate-plans/{id}:archiveArchiveRatePlanUseCase
POST /v1/admin/pricing/rate-plans
{
"code": "BAR",
"displayName": { "en": "Best Available Rate", "ps": "د موجود غوره نرخ", "fa": "بهترین نرخ موجود" },
"category": "BAR",
"channelScope": "all",
"currency": "USD",
"refundability": { "kind": "refundable", "cutoffHoursBefore": 48 },
"depositPolicy": { "kind": "first_night", "compoundingForbidden": true },
"advancePurchaseDays": 0,
"minLOS": 1,
"maxLOS": 30,
"shariaCompliant": false,
"packageInclusions": []
}

201 Created echoes the entity. The plan starts in draft. :publish requires at least one RateRule and any required FeeRule linkage.

3.2 Rate rules

MethodPathUse case
POST/v1/admin/pricing/rate-plans/{ratePlanId}/rulesAppendRateRuleUseCase
PATCH/v1/admin/pricing/rate-plans/{ratePlanId}/rules/{ruleId}UpdateRateRuleUseCase (If-Match REQUIRED)
DELETE/v1/admin/pricing/rate-plans/{ratePlanId}/rules/{ruleId}RetireRateRuleUseCase
GET/v1/admin/pricing/rate-plans/{ratePlanId}/ruleslist with filters
POST /v1/admin/pricing/rate-plans/rate_01H8Z4M0CNJW9N7QM4D3K6BVAN/rules
{
"priority": 100,
"scope": {
"dateRange": { "start": "2026-05-01", "end": "2026-09-30" },
"daysOfWeek": ["fri","sat"],
"roomTypeIds": ["rmt_01H8Z4M3PVHX9G7E0XV1KH9N6Q"],
"occupancyBands": [{ "minAdults": 1, "maxAdults": 2 }]
},
"baseMicro": "150000000:USD",
"multiplier": 1.20,
"surchargeMicro": "0:USD",
"los": { "minNights": 2, "discountPct": 5.0 },
"source": "manual"
}

3.3 Promotions

MethodPathUse case
POST/v1/admin/pricing/promotionsCreatePromotionUseCase
POST/v1/admin/pricing/promotions/{id}:activateActivatePromotionUseCase
POST/v1/admin/pricing/promotions/{id}:deactivateDeactivatePromotionUseCase
GET/v1/admin/pricing/promotionslist (filter: status, code)

3.4 Tax rules

MethodPathUse case
POST/v1/admin/pricing/tax-rulesUpsertTaxRuleUseCase
PATCH/v1/admin/pricing/tax-rules/{id}UpdateTaxRuleUseCase (creates a new validity window — taxes never overlap)
GET/v1/admin/pricing/tax-rulesfilter by country, region, effectiveAt

3.5 Fee rules

MethodPathUse case
POST/v1/admin/pricing/fee-rulesUpsertFeeRuleUseCase
PATCH/v1/admin/pricing/fee-rules/{id}UpdateFeeRuleUseCase
GET/v1/admin/pricing/fee-rulesfilter by propertyId, effectiveAt

3.6 FX snapshots

MethodPathUse case
GET/v1/admin/pricing/fx-snapshots/latest?base=AFN&quote=USDlatest snapshot
POST/v1/admin/pricing/fx-snapshots:refreshRefreshFxSnapshotUseCase (manual trigger)
GET/v1/admin/pricing/fx-snapshots/history?base=…&quote=…&from=…&to=…history
POST /v1/admin/pricing/fx-snapshots:refresh
{ "bases": ["AFN","IRR","TJS"], "quotes": ["USD"] }

3.7 Dynamic pricing suggestions (HITL)

MethodPathUse case
POST/v1/admin/pricing/dynamic-suggestions:generateGenerateDynamicPricingSuggestionUseCase
GET/v1/admin/pricing/dynamic-suggestionslist open (filter: propertyId, status)
GET/v1/admin/pricing/dynamic-suggestions/{id}read
POST/v1/admin/pricing/dynamic-suggestions/{id}:acceptAcceptDynamicPricingSuggestionUseCase (creates rate rule)
POST/v1/admin/pricing/dynamic-suggestions/{id}:rejectmark rejected (no rule created)
POST /v1/admin/pricing/dynamic-suggestions/dps_01H8Z4MZAB…:accept
{
"ratePlanId": "rate_01H8Z4M0CNJW9N7QM4D3K6BVAN",
"chosenMicro": "168000000:USD",
"reason": "Eid weekend demand"
}

200 OK returns the new RateRule. 409 urn:problem:melmastoon.ai.hitl_required if the suggestion has already been accepted/rejected/expired.


4. Internal inter-service endpoints (pricing-internal.svc.cluster.local)

These endpoints are reachable only from the GKE service mesh with mTLS; the API Gateway does not forward to them. They power the booking saga in reservation-service and the desktop sync stream.

MethodPathDescriptionCaller
POST/internal/v1/quotes:calculateSame as /v1/pricing/quotes but accepts a service-to-service tokenreservation-service
GET/internal/v1/quotes/{id}Same as public readreservation-service, billing-service
POST/internal/v1/quotes/{id}:lockConvert quote live → locked for a reservation; returns lock token; idempotentreservation-service
POST/internal/v1/quotes/{id}:releaseRelease lock on cancellationreservation-service
GET/internal/v1/sync/rate-plans?since={cursor}&propertyId=…Replication stream for desktop sync (paged, with watermark)desktop-sync-service
GET/internal/v1/sync/tax-rules?since={cursor}&jurisdiction=…Replication streamdesktop-sync-service
GET/internal/v1/sync/fx-snapshots?since={cursor}Replication streamdesktop-sync-service
GET/internal/v1/healthz, /internal/v1/readyz, /internal/v1/livezProbesGKE / Cloud Run

:lock is the contract that prevents the booking saga from charging the guest at a price that the service has since recalculated. It writes a quote_locks row referencing reservationId, sets the quote status to locked, and emits no public event (the quote already has quote.created.v1).


5. Standard error envelope

{
"type": "urn:problem:melmastoon.pricing.fx_snapshot_stale",
"title": "FX snapshot stale",
"status": 409,
"detail": "Latest USD/AFN snapshot is past the hard-expiry threshold (72h).",
"instance": "/v1/pricing/quotes",
"code": "MELMASTOON.PRICING.FX_SNAPSHOT_STALE",
"retryable": true,
"retryAfterSeconds": 60,
"correlationId": "01H8Z4N12345…",
"tenantId": "tnt_01H8Z4M1QCV5RNSP2X4N6BYYGE"
}

The code field follows ERROR_CODES. Full catalogue contributed by this service:

CodeHTTPRetryable
MELMASTOON.PRICING.RATE_PLAN_NOT_FOUND404no
MELMASTOON.PRICING.RATE_PLAN_INACTIVE409no
MELMASTOON.PRICING.RATE_PLAN_NOT_PUBLISHABLE422no
MELMASTOON.PRICING.RULE_OVERLAP409no
MELMASTOON.PRICING.STALE_VERSION409no
MELMASTOON.PRICING.FX_SNAPSHOT_INVALID422no
MELMASTOON.PRICING.FX_SNAPSHOT_STALE409yes
MELMASTOON.PRICING.PROMO_OVEROBLIGATION409no
MELMASTOON.PRICING.PROMO_NOT_APPLICABLE409no
MELMASTOON.PRICING.PROMO_CODE_COLLISION409no
MELMASTOON.PRICING.SHARIA_GUARD_FAILED409no
MELMASTOON.PRICING.DERIVATION_FAILED422no
MELMASTOON.PRICING.QUOTE_EXPIRED409no
MELMASTOON.PRICING.CURRENCY_MISMATCH422no
MELMASTOON.PRICING.NEGATIVE_TOTAL422no

6. Rate limiting

SurfaceLimit
POST /v1/pricing/quotes (per tenantId + booker_session_id)60 req/min, burst 20
POST /v1/pricing/promotions/validate30 req/min
POST /v1/admin/pricing/dynamic-suggestions:generate10 req/min/property
POST /v1/admin/pricing/fx-snapshots:refresh5 req/hour/tenant
Internal /internal/v1/quotes:calculategoverned by reservation-service token quota

Limits are enforced at the API Gateway (Cloud Armor + Redis token bucket). Excess returns 429 with Retry-After.


7. OpenAPI generation

The canonical spec is generated from NestJS controllers + @nestjs/swagger decorators and committed to services/pricing-service/openapi/pricing-v1.yaml. CI fails when the regenerated spec drifts from the committed file. Public consumers (booking BFF, Algolia search BFF, Electron desktop) generate clients from the published spec via Orval.