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
| Concern | Rule |
|---|---|
| Base path | /v1 |
| Public host | api.melmastoon.tech |
| Admin host | admin-api.melmastoon.tech (internal) |
| Auth | OAuth2 access token (Authorization: Bearer <jwt>) |
| Tenancy | X-Tenant-Id MUST match tenant_id claim in JWT |
| Idempotency | Idempotency-Key REQUIRED on POST/PATCH/DELETE that mutate state |
| Tracing | traceparent/tracestate per W3C Trace Context |
| Pagination | Cursor-based: ?limit=, ?cursor=; response carries nextCursor |
| Money on the wire | Strings as "<microUnits>:<currency>" (e.g. "125000000:USD" = 125.00 USD) |
| Errors | RFC 7807 application/problem+json per ERROR_CODES |
| Versioning | URL major (/v1); breaking changes ship as /v2 per 05 §10 |
| OpenAPI | services/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:
| HTTP | type (URN) | title |
|---|---|---|
400 | urn:problem:melmastoon.general.validation_failed | invalid request body |
404 | urn:problem:melmastoon.pricing.rate_plan_not_found | no candidate rate plan matched |
409 | urn:problem:melmastoon.pricing.fx_snapshot_stale | FX snapshot past hard expiry; retry after melmastoon.pricing.fx_snapshot.updated.v1 |
409 | urn:problem:melmastoon.pricing.sharia_guard_failed | Sharia plan attempted with riba_forbidden fee |
409 | urn:problem:melmastoon.pricing.promo_overobligation | promo cap reached |
422 | urn:problem:melmastoon.pricing.derivation_failed | rule 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
| Method | Path | Use case |
|---|---|---|
POST | /v1/admin/pricing/rate-plans | CreateRatePlanUseCase |
GET | /v1/admin/pricing/rate-plans | list (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}:publish | PublishRatePlanUseCase |
POST | /v1/admin/pricing/rate-plans/{id}:archive | ArchiveRatePlanUseCase |
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
| Method | Path | Use case |
|---|---|---|
POST | /v1/admin/pricing/rate-plans/{ratePlanId}/rules | AppendRateRuleUseCase |
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}/rules | list 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
| Method | Path | Use case |
|---|---|---|
POST | /v1/admin/pricing/promotions | CreatePromotionUseCase |
POST | /v1/admin/pricing/promotions/{id}:activate | ActivatePromotionUseCase |
POST | /v1/admin/pricing/promotions/{id}:deactivate | DeactivatePromotionUseCase |
GET | /v1/admin/pricing/promotions | list (filter: status, code) |
3.4 Tax rules
| Method | Path | Use case |
|---|---|---|
POST | /v1/admin/pricing/tax-rules | UpsertTaxRuleUseCase |
PATCH | /v1/admin/pricing/tax-rules/{id} | UpdateTaxRuleUseCase (creates a new validity window — taxes never overlap) |
GET | /v1/admin/pricing/tax-rules | filter by country, region, effectiveAt |
3.5 Fee rules
| Method | Path | Use case |
|---|---|---|
POST | /v1/admin/pricing/fee-rules | UpsertFeeRuleUseCase |
PATCH | /v1/admin/pricing/fee-rules/{id} | UpdateFeeRuleUseCase |
GET | /v1/admin/pricing/fee-rules | filter by propertyId, effectiveAt |
3.6 FX snapshots
| Method | Path | Use case |
|---|---|---|
GET | /v1/admin/pricing/fx-snapshots/latest?base=AFN"e=USD | latest snapshot |
POST | /v1/admin/pricing/fx-snapshots:refresh | RefreshFxSnapshotUseCase (manual trigger) |
GET | /v1/admin/pricing/fx-snapshots/history?base=…"e=…&from=…&to=… | history |
POST /v1/admin/pricing/fx-snapshots:refresh
{ "bases": ["AFN","IRR","TJS"], "quotes": ["USD"] }
3.7 Dynamic pricing suggestions (HITL)
| Method | Path | Use case |
|---|---|---|
POST | /v1/admin/pricing/dynamic-suggestions:generate | GenerateDynamicPricingSuggestionUseCase |
GET | /v1/admin/pricing/dynamic-suggestions | list open (filter: propertyId, status) |
GET | /v1/admin/pricing/dynamic-suggestions/{id} | read |
POST | /v1/admin/pricing/dynamic-suggestions/{id}:accept | AcceptDynamicPricingSuggestionUseCase (creates rate rule) |
POST | /v1/admin/pricing/dynamic-suggestions/{id}:reject | mark 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.
| Method | Path | Description | Caller |
|---|---|---|---|
POST | /internal/v1/quotes:calculate | Same as /v1/pricing/quotes but accepts a service-to-service token | reservation-service |
GET | /internal/v1/quotes/{id} | Same as public read | reservation-service, billing-service |
POST | /internal/v1/quotes/{id}:lock | Convert quote live → locked for a reservation; returns lock token; idempotent | reservation-service |
POST | /internal/v1/quotes/{id}:release | Release lock on cancellation | reservation-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 stream | desktop-sync-service |
GET | /internal/v1/sync/fx-snapshots?since={cursor} | Replication stream | desktop-sync-service |
GET | /internal/v1/healthz, /internal/v1/readyz, /internal/v1/livez | Probes | GKE / 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:
| Code | HTTP | Retryable |
|---|---|---|
MELMASTOON.PRICING.RATE_PLAN_NOT_FOUND | 404 | no |
MELMASTOON.PRICING.RATE_PLAN_INACTIVE | 409 | no |
MELMASTOON.PRICING.RATE_PLAN_NOT_PUBLISHABLE | 422 | no |
MELMASTOON.PRICING.RULE_OVERLAP | 409 | no |
MELMASTOON.PRICING.STALE_VERSION | 409 | no |
MELMASTOON.PRICING.FX_SNAPSHOT_INVALID | 422 | no |
MELMASTOON.PRICING.FX_SNAPSHOT_STALE | 409 | yes |
MELMASTOON.PRICING.PROMO_OVEROBLIGATION | 409 | no |
MELMASTOON.PRICING.PROMO_NOT_APPLICABLE | 409 | no |
MELMASTOON.PRICING.PROMO_CODE_COLLISION | 409 | no |
MELMASTOON.PRICING.SHARIA_GUARD_FAILED | 409 | no |
MELMASTOON.PRICING.DERIVATION_FAILED | 422 | no |
MELMASTOON.PRICING.QUOTE_EXPIRED | 409 | no |
MELMASTOON.PRICING.CURRENCY_MISMATCH | 422 | no |
MELMASTOON.PRICING.NEGATIVE_TOTAL | 422 | no |
6. Rate limiting
| Surface | Limit |
|---|---|
POST /v1/pricing/quotes (per tenantId + booker_session_id) | 60 req/min, burst 20 |
POST /v1/pricing/promotions/validate | 30 req/min |
POST /v1/admin/pricing/dynamic-suggestions:generate | 10 req/min/property |
POST /v1/admin/pricing/fx-snapshots:refresh | 5 req/hour/tenant |
Internal /internal/v1/quotes:calculate | governed 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.