API_CONTRACTS — billing-service
Conforms to 05 API Design. Base path
/api/v1. Hosted underhttps://billing.api.melmastoon.appand brokered throughbff-backoffice-service(Electron desktop) andbff-tenant-booking-service(web). Auth: OAuth2 access token fromiam-service. Tenancy:X-Tenant-Idheader is required on every request and must match the token claim. Idempotency:Idempotency-Keyis required on everyPOSTthat creates or mutates monetary state (folio, charges, payments, refunds, close, credit notes, cash-session lifecycle, subscription cycle/reactivate).
1. Conventions
- Money is encoded as
{ "amountMicro": "<bigint string>", "currency": "AFN" }. JSON numbers are unsafe for bigints; the API rejects numericamountMicrowithMELMASTOON.GENERAL.VALIDATION_FAILED. - IDs are ULIDs with prefixes (
fol_,chg_,fpm_,frd_,inv_doc_,cnt_,sub_,sin_,cdr_,cds_,set_,usg_). - Errors follow
{ "error": { "code": "MELMASTOON.BILLING.…", "message": "…", "details": { … }, "traceId": "…" } }. - Pagination is cursor-based:
?limit=50&cursor=<opaque>; response carriespagination: { nextCursor, hasMore }. - ETags returned on every aggregate read;
If-Matchenforces OCC on writes. RFC 9457 Problem Detailsenvelope wrapserrorfor 4xx/5xx.
2. Resource map
| Resource | Methods | Aggregate |
|---|---|---|
/folios | POST GET | Folio |
/folios/:id | GET PATCH (metadata only) | Folio |
/folios/:id/charges | POST GET | FolioCharge |
/folios/:id/charges/:chargeId | GET DELETE (admin void; audited) | FolioCharge |
/folios/:id/payments | POST GET | FolioPayment |
/folios/:id/refunds | POST GET | FolioRefund |
/folios/:id/close | POST | Folio (transitions to closed) |
/folios/:id/reopen | POST | Folio (transitions to re_opened) |
/folios/:id/balance | GET | computed |
/folios/:id/settlement | GET | Settlement |
/invoices | GET (search) | Invoice |
/invoices/:id | GET | Invoice |
/invoices/:id.pdf | GET | rendered PDF (302 to signed URL) |
/invoices/:id/send | POST | triggers notification-service |
/invoices/:id/credit-notes | POST GET | CreditNote |
/credit-notes/:id | GET | CreditNote |
/credit-notes/:id.pdf | GET | rendered PDF (302) |
/cash-drawers | GET | CashDrawer |
/cash-drawers/:id | GET | CashDrawer |
/cash-drawers/:id/sessions | POST GET | CashDrawerSession |
/cash-sessions/:id | GET | CashDrawerSession |
/cash-sessions/:id/initiate-close | POST | CashDrawerSession |
/cash-sessions/:id/close | POST | CashDrawerSession |
/cash-sessions/:id/acknowledge-discrepancy | POST | CashDrawerSession |
/cash-sessions/:id/reconciliation | GET | computed |
/subscriptions/:tenantId | GET | Subscription |
/subscriptions/:tenantId/usage | GET | UsageRecord |
/subscriptions/:tenantId/cycle | POST (platform admin) | SubscriptionInvoice |
/subscriptions/:tenantId/reactivate | POST (platform admin) | Subscription |
/subscription-invoices/:id | GET | SubscriptionInvoice |
/subscription-invoices/:id.pdf | GET | rendered PDF (302) |
3. Folio endpoints
POST /api/v1/folios — open folio (manual)
Used when the staff wants to pre-open a folio outside the reservation event-driven path (e.g., walk-in not yet checked-in or test). Idempotent.
POST /api/v1/folios HTTP/1.1
X-Tenant-Id: t_01HW…
Idempotency-Key: 4b3b… (UUID)
Authorization: Bearer …
Content-Type: application/json
{
"reservationId": "res_01HW…",
"currency": "AFN"
}
201:
{
"data": {
"id": "fol_01HW…",
"tenantId": "t_01HW…",
"propertyId": "prop_01HW…",
"reservationId": "res_01HW…",
"currency": "AFN",
"status": "open",
"balance": { "amountMicro": "0", "currency": "AFN" },
"openedAt": "2026-04-22T08:14:11Z",
"version": 1
}
}
Errors: 409 BILLING_FOLIO_ALREADY_EXISTS, 422 BILLING_FX_RATE_MISSING, 404 RESERVATION_NOT_FOUND.
GET /api/v1/folios/:id
Returns the folio with computed balance, embedded charge / payment / refund summary counts, and eTag. Use ?include=lines to embed line arrays (paginated).
POST /api/v1/folios/:id/charges — post charge
{
"kind": "mini_bar",
"description": { "default": "Mini-bar — Coca-Cola 330ml ×2", "locales": { "ps": "ميني بار - کوکا کولا ۳۳۰ مل ×۲", "ar": "البار الصغير - كوكاكولا 330 مل × 2" } },
"quantity": 2,
"unitPriceMicro": "75000000",
"currency": "AFN",
"taxCode": "VAT_STANDARD",
"customerClass": "individual",
"source": { "kind": "pos", "ref": "pos_ticket_482" }
}
201:
{
"data": {
"id": "chg_01HW…",
"folioId": "fol_01HW…",
"kind": "mini_bar",
"gross": { "amountMicro": "150000000", "currency": "AFN" },
"tax": { "code": "VAT_STANDARD", "amount": { "amountMicro": "15000000", "currency": "AFN" }, "rateNumerator": "10", "rateDenominator": "100" },
"postedAt": "2026-04-22T08:30:00Z",
"version": 2
}
}
Errors: 422 BILLING_CHARGE_INVALID, 422 BILLING_TAX_RULE_MISSING, 422 BILLING_SHARIA_COMPLIANT_VIOLATION, 409 BILLING_FOLIO_LOCKED, 412 PRECONDITION_FAILED.
POST /api/v1/folios/:id/payments — record payment
Two shapes accepted (cash vs external).
Cash:
{
"method": "cash",
"amountMicro": "200000000",
"currency": "AFN",
"cashSessionId": "cds_01HW…",
"metadata": { "receivedBy": "actor_…", "location": "front_desk" }
}
External (link to payment-gateway):
{
"method": "card",
"amountMicro": "200000000",
"currency": "USD",
"externalPaymentId": "pay_01HW…"
}
201: returns the FolioPayment resource. Errors: 422 BILLING_CASH_SESSION_REQUIRED, 422 BILLING_EXTERNAL_PAYMENT_REQUIRED, 409 BILLING_FOLIO_LOCKED, 409 BILLING_CASH_SESSION_NOT_OPEN.
POST /api/v1/folios/:id/refunds
{
"amountMicro": "50000000",
"currency": "AFN",
"reason": "Mini-bar item double-charged",
"method": "cash",
"cashSessionId": "cds_01HW…",
"issueCreditNoteForInvoiceId": "inv_doc_01HW…"
}
Or method:"original" to reverse against the linked external paymentId.
Errors: 422 BILLING_REFUND_EXCEEDS_BALANCE, 422 BILLING_REFUND_POLICY_VIOLATION, 409 BILLING_FOLIO_LOCKED.
POST /api/v1/folios/:id/close
{ "actor": "actor_…", "issueInvoice": true, "invoiceCustomer": { "class": "individual", "name": "Asma Rashid", "email": "asma@example.com", "preferredLocale": "ps", "vatNumber": null } }
200:
{
"data": {
"folio": { "id": "fol_…", "status": "closed", "version": 14, "closedAt": "2026-04-22T11:02:33Z" },
"settlement": { "id": "set_…", "perCurrencyTotals": [ { "currency": "AFN", "amountMicro": "350000000" } ], "residual": { "amountMicro": "0", "currency": "AFN" } },
"invoice": { "id": "inv_doc_…", "number": "INV-AF-2026-000142", "pdfUrl": "https://files.melmastoon.app/signed/…" }
}
}
Errors: 409 BILLING_BALANCE_DUE (with details.balance), 409 BILLING_FOLIO_ALREADY_CLOSED, 422 BILLING_INVOICE_RENDER_FAILED (deferred path triggered).
POST /api/v1/folios/:id/reopen
Requires billing.folio.reopen scope. Body: { "reason": "Restaurant charge added 30min after checkout" }. Voids any prior invoice.
GET /api/v1/folios/:id/balance & GET /api/v1/folios/:id/settlement
Read-only views over computed state. Settlement is only present for status=closed.
4. Invoice & credit note endpoints
GET /api/v1/invoices/:id
{
"data": {
"id": "inv_doc_01HW…",
"tenantId": "t_…",
"folioId": "fol_…",
"number": "INV-AF-2026-000142",
"customer": { "class": "individual", "name": "…", "preferredLocale": "ps", "vatNumber": null },
"lines": [ { "description": { "default": "Room night × 3" }, "gross": { "amountMicro": "…", "currency": "AFN" }, "tax": { "code": "VAT_STANDARD", "amount": {…} } } ],
"subtotal": { "amountMicro": "…", "currency": "AFN" },
"taxTotal": { "amountMicro": "…", "currency": "AFN" },
"grandTotal": { "amountMicro": "…", "currency": "AFN" },
"currency": "AFN",
"locale": "ps-AF",
"template": "standard",
"issuedAt": "2026-04-22T11:02:33Z",
"voidedAt": null,
"pdfUrl": "https://files.melmastoon.app/signed/…"
}
}
GET /api/v1/invoices/:id.pdf
302 redirect to signed URL valid 5 minutes. Pure GET; no idempotency key.
POST /api/v1/invoices/:id/send
{ "channel": "email", "to": "asma@example.com", "subject": "Your invoice from Hotel Pamir" }
Publishes invoice.send request to notification-service. Emits invoice.sent.v1 once notification-service ACKs delivery.
POST /api/v1/invoices/:id/credit-notes
{
"lines": [ { "originalLineId": "ln_…", "amountMicro": "15000000", "currency": "AFN", "reason": "POS double-charge" } ],
"reason": "Customer dispute resolved",
"issuedAt": "2026-04-22T13:14:00Z"
}
5. Cash drawer endpoints
POST /api/v1/cash-drawers/:id/sessions
{
"openingFloat": { "amountMicro": "5000000000", "currency": "AFN" },
"openedBy": "actor_…",
"shiftLabel": "Day"
}
Errors: 409 BILLING_CASH_DRAWER_PRIOR_SESSION_OPEN.
POST /api/v1/cash-sessions/:id/initiate-close
{
"countedClosingFloat": { "amountMicro": "8500000000", "currency": "AFN" },
"closingActor": "actor_…"
}
Returns the session in pending_close.
POST /api/v1/cash-sessions/:id/close
{
"coSigner": "actor_…",
"stepUpToken": "step_up_01HW…"
}
Constraints enforced server-side:
- The request must originate from a session that the platform deems online (heartbeat freshness ≤ 30 s on the desktop's
cloud-syncchannel). - The
coSignermust differ from theclosingActor. - The
stepUpTokenis verified withiam-servicesynchronously.
200:
{
"data": {
"id": "cds_…",
"status": "closed",
"expectedClosingFloat": { "amountMicro": "8500000000", "currency": "AFN" },
"countedClosingFloat": { "amountMicro": "8500000000", "currency": "AFN" },
"variance": { "amountMicro": "0", "currency": "AFN" },
"closedAt": "2026-04-22T22:14:00Z",
"closedBy": "actor_…",
"coSigner": "actor_…"
}
}
Errors: 409 BILLING_CASH_DRAWER_OFFLINE_CLOSE_FORBIDDEN, 409 BILLING_CASH_DRAWER_COSIGNER_MUST_DIFFER, 401 IAM_STEP_UP_REJECTED. If variance exceeds threshold, the session closes with status: "reconciliation_blocked" and an additional discrepancy: { … } block.
POST /api/v1/cash-sessions/:id/acknowledge-discrepancy
{ "actor": "actor_…", "coSigner": "actor_…", "writtenReason": "Counted twice; consistent shortfall — escalating to GM" }
Transitions reconciliation_blocked → closed and unblocks the next session start.
GET /api/v1/cash-sessions/:id/reconciliation
{
"data": {
"session": { "id": "cds_…", "openedAt": "…", "closedAt": "…", "status": "closed" },
"openingFloat": { "amountMicro": "5000000000", "currency": "AFN" },
"totalReceipts": { "amountMicro": "3500000000", "currency": "AFN" },
"totalRefunds": { "amountMicro": "0", "currency": "AFN" },
"expectedClosingFloat": { "amountMicro": "8500000000", "currency": "AFN" },
"countedClosingFloat": { "amountMicro": "8500000000", "currency": "AFN" },
"variance": { "amountMicro": "0", "currency": "AFN" },
"folioReceipts": [ { "folioId": "fol_…", "paymentId": "fpm_…", "amount": { "amountMicro": "200000000", "currency": "AFN" } } ]
}
}
6. Subscription endpoints
GET /api/v1/subscriptions/:tenantId
Reads from the central schema. Caller must hold subscription.read for own tenant or platform.subscription.read for any tenant.
{
"data": {
"id": "sub_…",
"tenantId": "t_…",
"plan": { "code": "PRO_PER_ROOM", "base": { "kind": "per_room_month", "perRoomAmount": { "amountMicro": "5000000000", "currency": "USD" } }, "meters": [ { "meter": "ai_tokens", "included": "100000", "overageUnit": { "amountMicro": "10", "currency": "USD" }, "perUnits": "1" } ] },
"state": "current",
"currency": "USD",
"cycleAnchor": 1,
"createdAt": "2026-01-12T08:00:00Z"
}
}
GET /api/v1/subscriptions/:tenantId/usage?period=2026-04
Returns aggregated UsageRecords for the period.
POST /api/v1/subscriptions/:tenantId/cycle (platform admin)
{ "period": "2026-04", "force": false }
Errors: 409 BILLING_SUBSCRIPTION_CYCLE_ALREADY_RUN, 409 BILLING_SUBSCRIPTION_HARD_CAP_EXCEEDED (with details.suspended=true).
POST /api/v1/subscriptions/:tenantId/reactivate (platform admin)
{ "actor": "platform_actor_…", "reason": "Tenant settled outstanding invoice" }
GET /api/v1/subscription-invoices/:id
Returns the subscription invoice. Includes dunningHistory: [{ state, at, notificationId }].
7. Webhooks (outgoing)
billing-service does not expose inbound webhooks; it consumes events from Pub/Sub. Subscription-payment outcomes flow through payment-gateway-service → Pub/Sub → our handlers.
8. OpenAPI fragment (selected schemas)
openapi: 3.1.0
info: { title: melmastoon-billing, version: 1.0.0 }
components:
schemas:
Money:
type: object
required: [amountMicro, currency]
properties:
amountMicro: { type: string, pattern: "^-?\\d+$" }
currency: { type: string, enum: [AFN, USD, EUR, PKR, SAR, AED, TJS, IRR, GBP, TRY] }
FolioStatus:
type: string
enum: [pending, open, balance_due, settled, closed, re_opened]
Folio:
type: object
required: [id, tenantId, propertyId, reservationId, currency, status, balance, openedAt, version]
properties:
id: { type: string }
tenantId: { type: string }
propertyId: { type: string }
reservationId: { type: string }
currency: { type: string }
status: { $ref: '#/components/schemas/FolioStatus' }
balance: { $ref: '#/components/schemas/Money' }
openedAt: { type: string, format: date-time }
closedAt: { type: string, format: date-time, nullable: true }
version: { type: integer }
PostChargeRequest:
type: object
required: [kind, description, quantity, unitPriceMicro, currency, taxCode, customerClass, source]
properties:
kind: { type: string, enum: [room_night, tax, fee, mini_bar, restaurant, laundry, service, adjustment, late_fee] }
description:
type: object
required: [default]
properties:
default: { type: string }
locales: { type: object, additionalProperties: { type: string } }
quantity: { type: integer, minimum: 1 }
unitPriceMicro: { type: string }
currency: { type: string }
taxCode: { type: string }
customerClass: { type: string, enum: [individual, corporate, government, agent, sharia] }
source:
type: object
required: [kind]
properties:
kind: { type: string, enum: [rate_plan, pos, manual, event] }
ref: { type: string }
Error:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, traceId]
properties:
code: { type: string, pattern: "^MELMASTOON\\.[A-Z]+\\.[A-Z_]+$" }
message: { type: string }
details: { type: object, additionalProperties: true }
traceId: { type: string }
9. Auth & RBAC scopes
| Scope | Endpoints |
|---|---|
billing.folio.read | all GET /folios/* |
billing.folio.write | POST /folios, POST /folios/:id/charges, POST /folios/:id/payments, POST /folios/:id/refunds, POST /folios/:id/close |
billing.folio.reopen | POST /folios/:id/reopen |
billing.invoice.read | all GET /invoices/* |
billing.invoice.send | POST /invoices/:id/send |
billing.credit_note.write | POST /invoices/:id/credit-notes |
billing.cash_drawer.operate | POST /cash-drawers/:id/sessions, POST /cash-sessions/:id/initiate-close |
billing.cash_drawer.close | POST /cash-sessions/:id/close (requires step-up) |
billing.cash_drawer.acknowledge_discrepancy | POST /cash-sessions/:id/acknowledge-discrepancy |
subscription.read | own GET /subscriptions/:tenantId |
platform.subscription.read | any tenant |
platform.subscription.write | POST /cycle, POST /reactivate |
10. Rate limits
| Surface | Limit |
|---|---|
POST /folios/:id/charges | 60 rps per (tenantId, folioId) |
POST /folios/:id/payments | 30 rps per (tenantId, folioId) |
GET /invoices/:id.pdf | 5 rps per (tenantId, invoiceId) |
POST /cash-sessions/:id/close | 1 rps per (tenantId, sessionId) |
POST /subscriptions/:tenantId/cycle | 1 rps platform-wide |
11. Versioning
Endpoint version /api/v1. Breaking changes go to /api/v2 with a deprecation header on v1 for 6 months. Event payloads version independently (.v1, .v2) per 04 §4.