Skip to content

API SPECIFICATION

VietGara — Multi-Tenant SaaS Platform for Auto Garage Management & Maintenance

Version 1.11 — October 12, 2026 · Cloud: Google Cloud Platform (GCP) · Backend: Go

1. Introduction

This document specifies the client-facing REST/JSON API of the VietGara platform, exposed through the API Gateway (Kong). The same operations are available over gRPC for internal callers (ADR-006). The source of truth is the proto contract; the generated OpenAPI document vietgara-proto/openapi/vietgara.swagger.json lists every field and is what the owner web generates its TypeScript types from. This document describes the conventions and the endpoint catalogue.

flowchart LR
  proto[".proto + google.api.http"] --> gw["grpc-gateway<br/>(REST in the backend)"]
  proto --> oas["OpenAPI v2 document"]
  oas --> ts["Owner web and platform admin types"]
  client[Client] -->|"HTTPS /api/v1/…"| kong[Kong] --> gw -->|in-process gRPC| svc[Interceptors + services]

1.1. Conventions

TopicConvention
Base path/api/v1/… (major version in the path)
HostsThe web apps call /api on their own origin (vietgara.viettechno.com, vietgara-admin.viettechno.com). Mobile apps and integrations call the API-only host https://vietgara-api.viettechno.com/api. Staging appends -staging to the first label (Deployment Architecture Section 2.2)
ResourcesPlural nouns. Garage data lives under /api/v1/garages/{garageId}/… — the garage in the path is the tenant context (no header). The caller’s own data lives under /api/v1/me/…; platform administration under /api/v1/admin/… (called only by the Admin Web)
JSONcamelCase field names; enums as their names (GARAGE_STATUS_ACTIVE); timestamps RFC 3339 UTC; money as integer VND strings ("299000"); bytes as base64; fields with default values are included
ReadGET /resources (list), GET /resources/{id}
CreatePOST /resources with the resource as the body → 201 Created with the resource
UpdatePATCH /resources/{id} with the fields to change. Only the JSON keys present are updated (the field mask is derived from them); updateMask may be given explicitly
DeleteDELETE /resources/{id} → 204 No Content. Resources that other records refer to (customers, vehicles, suppliers, files) answer 200 with the outcome instead, see Section 1.5
ActionsA sub-resource named by a verb or noun: POST …/invitations/{id}/accept, POST /me/subscription/plan-changes → 200 with the updated resource. A state change of one object is a custom verb: POST …/customers/{id}:activate, POST …/payments/{id}:void
Phone and e-mailA phone is accepted in national (0901 234 567) or international form and returned in E.164 (+84901234567); an e-mail is returned trimmed and lower-cased. An invalid value is VALIDATION_ERROR (invalid). Customer search by phone matches either form
Lists{ "data": [...], "pagination": { "total", "page", "pageSize" } }. Paged lists take page (from 1) and pageSize (default 20, max 100); short lists (garages, roles, staff) return data only
Search/filterQuery parameters: query (free text), and typed filters such as status, type, customerId
AuthenticationAuthorization: Bearer <access token> on every endpoint except sign-up, sign-in, token refresh and password reset
LanguageErrors carry stable codes; the UI translates them. E-mails use the user’s locale (LOCALE_VI default, LOCALE_EN)
IdempotencyCreation APIs with financial impact require an Idempotency-Key header: POST …/settlements and POST …/settlements/{settlementId}/payments. See Section 1.3

1.2. Errors

Every error has the same envelope:

{
"error": {
"code": "VALIDATION_ERROR",
"message": "full_name is required",
"details": [{ "field": "fullName", "issue": "required" }]
}
}

code is stable and machine-readable; message is for developers; details lists field issues (required, invalid, too_long, taken, expired, …).

HTTPWhenTypical codes
400Malformed requestBAD_REQUEST
401Missing/invalid token or credentialsUNAUTHENTICATED, INVALID_CREDENTIALS, REFRESH_TOKEN_INVALID
402The owner’s plan blocks the actionSUBSCRIPTION_SUSPENDED, PLAN_LIMIT_REACHED
403Authenticated but not allowedEMAIL_NOT_VERIFIED, PERMISSION_DENIED, GARAGE_ACCESS_DENIED, OWNER_ONLY, SUBSCRIPTION_READ_ONLY, USER_SUSPENDED
404Not found (or not visible to the caller)NOT_FOUND, USER_NOT_FOUND
409Conflict with the current stateEMAIL_TAKEN, ALREADY_MEMBER, ALREADY_INVITED, ROLE_NAME_TAKEN, ROLE_IN_USE, GARAGE_INACTIVE, INVITATION_NOT_PENDING, INVITATION_EXPIRED, EMAIL_ALREADY_VERIFIED, REQUEST_ALREADY_OPEN, REQUEST_NOT_OPEN, ALREADY_EXISTS (a customer with the same phone, a supplier with the same name, a plate already registered, a repair order already settled, a voucher already voided, a customer already linked to a user), VEHICLE_FIELD_LOCKED (an identity field edited while a live document refers to the vehicle), INVOICE_DATA_INCOMPLETE (a VAT invoice requested for a billing party without legal name, tax ID or address), FAILED_PRECONDITION (no user owns the customer’s verified e-mail; a code confirmed with no request waiting; a request already answered)
422Semantically invalid inputVALIDATION_ERROR, OTP_INVALID, OTP_EXPIRED, RESET_TOKEN_INVALID, PLAN_NOT_AVAILABLE, TRIAL_NOT_AVAILABLE, EVIDENCE_REQUIRED, AMOUNT_MISMATCH_NOTE_REQUIRED, INVALID_LOGO, QUOTE_LINK_INVALID, IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_KEY_REUSED
429Too many attemptsOTP_RESEND_TOO_SOON, OTP_TOO_MANY_ATTEMPTS, RATE_LIMITED
500Unexpected failureINTERNAL

1.3. Idempotency Keys

A client that sends a payment and loses the answer (timeout, dropped connection) cannot tell whether the payment was recorded. With an idempotency key it can simply send the same request again:

CaseAnswer
No Idempotency-Key, or not 1–255 printable ASCII characters422 IDEMPOTENCY_KEY_REQUIRED; nothing happens
A key not seen beforeThe request runs. Its answer is stored with the key, in the same database transaction as the change it made
The same key with the same requestThe stored answer and status (e.g. 201), without running again. A duplicate sent while the first is still running waits for it, then gets its answer
The same key with another body, or for another endpoint422 IDEMPOTENCY_KEY_REUSED; nothing happens
A key whose request failedNothing was stored, so the key can be sent again; the request runs normally
  • Keys are scoped to the garage and the calling user: two staff members never collide.
  • Keys are kept 24 hours, far longer than any client retry.
  • Clients make one key per user action (a random UUID) and reuse it only for that action’s retries; a new payment gets a new key.
  • The owner web and the owner app do this (api.postIdempotent, ApiClient.postIdempotent).

1.4. Access Levels

The last column of the tables below uses these levels, enforced by the backend interceptors (Security Architecture, Section 3):

LevelMeaning
PublicNo token
Signed inAny valid token, even if the e-mail is not verified yet
VerifiedA verified user
permA verified user that owns the garage or is an active member holding permission perm there (e.g. customer.read)
MemberAny active member of the garage
OwnerThe garage owner
AdminA platform administrator (Admin Web and the Admin App sign these users in)
Supplier (linked)A verified user with a confirmed supplier link (Phase 17); sees only the purchase orders addressed to its supplier, never other garage or customer data

Garage-scoped writes are also refused while the owner’s subscription is past due (read-only) or suspended.

1.5. Delete or Deactivate (FR-CAT-07)

An object that quotes, repair orders or settlements refer to is never deleted from under them. Customers, vehicles, suppliers, files and, since Release 6.0, parts, units and part categories follow one rule (tax profiles and the other later catalogues will join it):

CallAnswer
DELETE …/{id}200 { "outcome": "DELETE_OUTCOME_DELETED" } when nothing refers to the object (it is soft-deleted), or { "outcome": "DELETE_OUTCOME_DEACTIVATED" } when something does: the object leaves pickers, stays on the documents that use it, and shows isActive: false
DELETE …/{id}?dryRun=trueThe same outcome without changing anything, so a client can word its confirmation (“this will deactivate the customer”)
POST …/{id}:activateReactivates a deactivated object and answers it with isActive: true (customers, vehicles, suppliers)
GET a list with includeInactive=trueAlso lists deactivated objects, for the staff screens. Pickers leave it off

What counts as “in use”: a customer is in use while a live quote or repair order refers to the customer or to any of its vehicles (a settlement belongs to a repair order); a vehicle, while a live quote or repair order refers to it; a file, while a business row refers to it (the garage logo; a replaced logo is retired, not deleted); a supplier has no references until goods receipts arrive (Phase 7), so it is always deleted until then. Quotes (drafts only), repair orders and inventory items keep their own delete rules.

Unique values (phone, plate, name) stay reserved by a deactivated object: reactivate it instead of creating a duplicate.

2. Identity & Access API

MethodPathDescriptionAccess
POST/api/v1/auth/registerRegister with email, password (≥ 8), fullName, optional phone, locale. Returns a session for an unverified user and e-mails a 6-digit OTP (FR-IAM-01)Public
POST/api/v1/auth/loginSign in with email + password; returns a sessionPublic
GET/api/v1/auth/google/loginStarts Google sign-in (browser redirect, opened in a popup)Public
GET/api/v1/auth/google/callbackGoogle redirect target; posts {type, session | error} to the opener windowPublic
POST/api/v1/auth/google/id-tokenMobile apps (Release 2.4): exchange idToken, a Google ID token obtained natively, plus an optional locale, for a session. The token’s signature, issuer, expiry and audience are checked; the audience must be one of the backend’s GOOGLE_MOBILE_CLIENT_IDS. The user is found or created as with the browser flow. Errors: GOOGLE_NOT_CONFIGURED (400, no client IDs configured), GOOGLE_TOKEN_INVALID (401), INVALID_CREDENTIALS (401, Google e-mail not verified)Public
POST/api/v1/auth/refreshExchange refreshToken for a new session (the refresh token rotates)Public
POST/api/v1/auth/logoutRevoke refreshTokenPublic
POST/api/v1/auth/email-verification/otpE-mail a new verification OTP; returns {expiresAt, resendAvailableAt}Signed in
POST/api/v1/auth/email-verificationVerify with otpCode and the current refreshToken; returns a new session whose token is verifiedSigned in
POST/api/v1/auth/password-reset/otpE-mail a reset OTP to email (same answer whether or not the user exists)Public
POST/api/v1/auth/password-reset/otp/verificationCheck email + otpCode; returns a short-lived resetTokenPublic
POST/api/v1/auth/password-resetSet newPassword with resetToken; revokes all sessionsPublic
GET/api/v1/meThe signed-in userSigned in
PATCH/api/v1/meUpdate fullName, phone, localeSigned in

A session is { accessToken, refreshToken, tokenType: "Bearer", expiresIn, user }. The access token carries only the user id, email_verified and platform_admin claims; garages and permissions are resolved per request.

sequenceDiagram
  participant W as Owner web
  participant A as API
  W->>A: POST /auth/register
  A-->>W: 201 session (emailVerified=false), OTP e-mailed
  W->>A: POST /auth/email-verification {otpCode, refreshToken}
  A-->>W: 200 session (emailVerified=true)
  Note over W,A: Forgotten password
  W->>A: POST /auth/password-reset/otp {email}
  W->>A: POST /auth/password-reset/otp/verification {email, otpCode}
  A-->>W: {resetToken}
  W->>A: POST /auth/password-reset {resetToken, newPassword}

3. Tenant/Garage Management API

3.1. Garages

MethodPathDescriptionAccess
GET/api/v1/garagesGarages the caller owns or works in, each with access { owner, permissions[] }Verified
POST/api/v1/garagesCreate a garage (counts against maxGarages); default staff groups are createdVerified
GET/api/v1/garages/{garageId}Garage detailsMember
PATCH/api/v1/garages/{garageId}Update name, taxCode, address, phone, email, timezone, currency, operatingHours[], numbering { quote, repairOrder, settlement, paymentVoucher }, defaultQuoteApprovalMode (LINK / MANUAL, FR-QUO-02) and the legal profile of FR-TEN-06: legalName, legalAddress, representative, bank { bankName, accountNumber, accountHolder } (the common BankDetails; bank is replaced as a whole). A numbering template has {YYYY}, {YY}, {MM}, {DD} and exactly one {SEQ:n}; an empty template is refused, so a client leaves one out to keep it. status (GARAGE_STATUS_ACTIVE / GARAGE_STATUS_INACTIVE) is owner-only. Documents issued earlier keep the identity they were issued with (Database Design 2.2)garage.write
PUT/api/v1/garages/{garageId}/logoUpload the logo: { content (base64), contentType } — PNG, JPEG or WebP, ≤ 1 MB. It is stored in the file registry (Section 6) and returned as logoUrl; a replaced logo is retired, and documents already issued keep printing theirsgarage.write
DELETE/api/v1/garages/{garageId}/logoRemove the logo; returns the garagegarage.write

3.2. Staff and Invitations

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/staffMembers (owner first) with status, groupIds, effective permissionsstaff.read
PATCH/api/v1/garages/{garageId}/staff/{staffId}Change status (STAFF_STATUS_ACTIVE / STAFF_STATUS_INACTIVE) and/or groupIdsstaff.write
DELETE/api/v1/garages/{garageId}/staff/{staffId}Remove a memberstaff.write
GET/api/v1/garages/{garageId}/staff-candidates?query=Find an existing user by e-mail or phone; returns membership (NONE / MEMBER / INVITED)staff.write
GET/api/v1/garages/{garageId}/invitations?status=The garage’s invitationsstaff.read
POST/api/v1/garages/{garageId}/invitationsInvite userId with groupIds (counts against maxStaff); e-mails the inviteestaff.write
POST/api/v1/garages/{garageId}/invitations/{invitationId}/revokeRevoke a pending invitationstaff.write
GET/api/v1/me/invitations?status=Invitations to the callerVerified
GET/api/v1/me/invitations/{invitationId}One invitation (the e-mail links to it)Verified
POST/api/v1/me/invitations/{invitationId}/acceptAccept: joins the garage and its groupsVerified
POST/api/v1/me/invitations/{invitationId}/declineDeclineVerified

3.3. Roles and Staff Groups

MethodPathDescriptionAccess
GET/api/v1/permissionsThe permission catalogue in display order: { permission, module, action }, action being read or writeVerified
GET/api/v1/rolesThe caller’s roles (shared by all their garages), with groupCount, isDefault and, for default roles, defaultPermissions (the template permissions “Reset to default” restores)Verified
POST/api/v1/rolesCreate { name, description, permissions[] }Verified
GET/api/v1/roles/{roleId}One roleVerified
PATCH/api/v1/roles/{roleId}Update name, description, permissions; permissions[] replaces the role’s whole setVerified (role owner)
DELETE/api/v1/roles/{roleId}Delete (refused with ROLE_IN_USE while a group uses it)Verified (role owner)
GET/api/v1/garages/{garageId}/staff-groupsThe garage’s groups with role and member countstaff.read
POST/api/v1/garages/{garageId}/staff-groupsCreate { name, description, roleId }Owner
PATCH/api/v1/garages/{garageId}/staff-groups/{groupId}UpdateOwner
DELETE/api/v1/garages/{garageId}/staff-groups/{groupId}DeleteOwner

Permissions are enum values such as PERMISSION_CUSTOMER_WRITE (catalogue customer.write, Security Architecture 3.1). When a role is created or updated, unknown values are refused with VALIDATION_ERROR, and every <module>.write permission is saved with its <module>.read permission, so a response may list more permissions than the request sent. The owner web sends the full set once per save, never one permission at a time.

4. License Management API

MethodPathDescriptionAccess
GET/api/v1/plansPlans an owner can choose, with price, billingCycle, limits { maxGarages, maxStaff, maxTransactionsPerDay }, features[]Signed in
GET/api/v1/me/subscriptionThe caller’s subscription (all its garages) with plan, usage { garages, staff, transactionsToday }, creditBalance, periodPaidAmount and openRequest { id, kind, planId, status, reference, amountDue, submittedAt } (null when none)Verified
POST/api/v1/me/subscription/payment-requestsStart a plan change or a renewal { planId, kind }, kind is CHANGE or RENEW (the current plan) → 201 with the request: { id, kind, planId, status: AWAITING_PAYMENT, reference, quotedAmount, creditApplied, amountDue, creditSurplus, periodDays, unusedDays, instructions { bankName, accountNumber, accountHolder, branch, transferInstructions, qrUrl } } (FR-LIC-07, FR-LIC-09). The usage is validated against the plan’s limits (402 PLAN_LIMIT_REACHED); the trial plan is 422 TRIAL_NOT_AVAILABLE; a plan that cannot be chosen is 422 PLAN_NOT_AVAILABLE; a request already SUBMITTED is 409 REQUEST_ALREADY_OPEN, while one still AWAITING_PAYMENT is replaced (cancelled, with a new reference). The amounts are fixed here and honoured at approvalVerified
GET/api/v1/me/subscription/payment-requests?status=The caller’s requests, newest firstVerified
GET/api/v1/me/subscription/payment-requests/{requestId}Request detail, with the evidence’s signed URL (5 minutes) once submitted, and the rejection reasonVerified
POST/api/v1/me/subscription/payment-requests/{requestId}:submitDeclare the transfer { payerName, payerBank, payerAccount, transferredAmount, bankTransactionRef, paidAt, evidence { content (base64), contentType } } → the request, SUBMITTED. The evidence (PNG, JPEG, WebP or PDF, at most 5 MB) is required unless amountDue is 0; 422 EVIDENCE_REQUIRED otherwise. The platform admins are notifiedVerified
POST/api/v1/me/subscription/payment-requests/{requestId}:cancelCancel a request that is AWAITING_PAYMENT or SUBMITTED (before review) → the request, CANCELLED; a reviewed request is 409 REQUEST_NOT_OPENVerified
GET/api/v1/me/subscription/credit-ledgerThe credit entries { kind, amount, balanceAfter, planPaymentRequestId, note, createdAt }, newest firstVerified
GET/api/v1/payment-settingsThe platform’s payment details shown with a request: { bankName, accountNumber, accountHolder, branch, transferInstructions, qrUrl } (qrUrl signed, 5 minutes)Verified
GET/api/v1/garages/{garageId}/entitlementsThe garage owner’s entitlements: status, planCode, features, limits, readOnly, currentPeriodEnd (drives the UI banners)Member
GET/api/v1/admin/plansEvery plan, including hidden and retired onesAdmin
POST/api/v1/admin/plansCreate a plan (code is immutable afterwards)Admin
PATCH/api/v1/admin/plans/{planId}Update price, limits, features, isPublic, isActive, sortOrderAdmin
GET/api/v1/admin/subscriptions?query=&status=&page=&pageSize=Search subscriptions by owner e-mail or name; each row carries creditBalance and openRequestAdmin
POST/api/v1/admin/subscriptions/{subscriptionId}/extensionsExtend by days (1–366); reactivates past-due and suspended subscriptions (a correction; renewals go through payment requests)Admin
POST/api/v1/admin/subscriptions/{subscriptionId}/credit-adjustmentsCorrect the account credit { amount (signed), note } → the ledger entry; the balance never goes below 0. AuditedAdmin
GET/api/v1/admin/plan-payment-requests?status=&query=&page=&pageSize=The review queue: requests with owner, plan, quote, declared amounts and amountMismatch; status defaults to SUBMITTED, oldest firstAdmin
GET/api/v1/admin/plan-payment-requests/{requestId}One request with the evidence’s signed URL, the owner’s current usage and planAdmin
POST/api/v1/admin/plan-payment-requests/{requestId}:approveApprove { note } (FR-LIC-08): the plan applies at once, a new period starts, the credit ledger and entitlements are updated → the subscription. The limits are validated again (402 PLAN_LIMIT_REACHED); when the amount transferred differs from amountDue a note is required (422 AMOUNT_MISMATCH_NOTE_REQUIRED); a request that is not SUBMITTED is 409 REQUEST_NOT_OPENAdmin
POST/api/v1/admin/plan-payment-requests/{requestId}:rejectReject { reason } (required) → the request, REJECTED; the owner is e-mailed and may submit a new requestAdmin
GET/api/v1/admin/payment-settingsThe platform payment detailsAdmin
PATCH/api/v1/admin/payment-settingsUpdate bankName, accountNumber, accountHolder, branch, transferInstructions by update mask; auditedAdmin
POST/api/v1/admin/payment-settings/qrUpload or replace the QR code { content (base64), contentType } (PNG, JPEG or WebP, at most 1 MB); auditedAdmin

5. Customer & Vehicle API

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/customers?query=&includeInactive=&page=&pageSize=Search customers by name, phone (national or international form) or e-mail. Deactivated customers only with includeInactive=truecustomer.read
POST/api/v1/garages/{garageId}/customersCreate { fullName, phone, email, address, personalDataConsent }. The phone is stored in E.164 and is unique per garage among live customers (409 ALREADY_EXISTS)customer.write
GET/api/v1/garages/{garageId}/customers/{customerId}Customer detail with vehicleCount, isActive and linkStatus (REQUESTED, LINKED, DECLINED, UNLINKED, or unspecified when never linked; the current link’s status, else the latest)customer.read
PATCH/api/v1/garages/{garageId}/customers/{customerId}Update (consent changes are logged)customer.write
DELETE/api/v1/garages/{garageId}/customers/{customerId}?dryRun=Delete or deactivate the customer (Section 1.5): an unused customer is deleted with its vehicles, one that quotes or repair orders refer to is deactivatedcustomer.write
POST/api/v1/garages/{garageId}/customers/{customerId}:activateReactivate a deactivated customercustomer.write
POST/api/v1/garages/{garageId}/customers/{customerId}/user-linksAsk the user who owns the customer’s verified e-mail to link (FR-CUS-06) → CustomerUserLink { id, customerId, status, channel, requestedAt, linkedAt }. A one-time code is e-mailed to that user (valid 2 minutes, resend after 30 seconds: 429 OTP_RESEND_TOO_SOON) and the request shows in their Customer App. The answer never says who the user is. A request already waiting for the same user sends the code again; one waiting for another user (the e-mail changed hands) ends. FAILED_PRECONDITION when the customer has no e-mail or no verified user owns it, 409 ALREADY_EXISTS when the customer is already linkedcustomer.write
POST/api/v1/garages/{garageId}/customers/{customerId}/user-links/current:confirmConfirm the waiting request with the code the user read out { otp } → the link, LINKED through channel OTP. OTP_INVALID / OTP_EXPIRED / OTP_TOO_MANY_ATTEMPTS as in Section 2; FAILED_PRECONDITION when no request waitscustomer.write
DELETE/api/v1/garages/{garageId}/customers/{customerId}/user-links/currentEnd the link, or cancel the waiting request, from the garage’s side → the link, UNLINKED; the profile stays. 404 when there is nonecustomer.write
GET/api/v1/garages/{garageId}/customers/{customerId}/tax-profiles?includeInactive=The customer’s tax profiles, the default first (FR-CUS-07)customer.read
POST/api/v1/garages/{garageId}/customers/{customerId}/tax-profilesCreate { kind, legalName, taxId, address, invoiceEmail, isDefault } → 201. kind is PERSONAL or COMPANY and never changes; legalName is required; taxId is 10 digits, or 13 with the branch suffix (spaces, dots and dashes are dropped); a second personal profile is VALIDATION_ERROR. Setting isDefault moves the mark from the previous defaultcustomer.write
GET / PATCH/api/v1/garages/{garageId}/customers/{customerId}/tax-profiles/{taxProfileId}Detail / update (legalName, taxId, address, invoiceEmail, isDefault by update mask; a blank string clears an optional field)customer.read / customer.write
DELETE/api/v1/garages/{garageId}/customers/{customerId}/tax-profiles/{taxProfileId}?dryRun=Delete or deactivate (Section 1.5): a profile a quote or settlement was billed to is deactivated, loses its default mark and can no longer be chosencustomer.write
POST/api/v1/garages/{garageId}/customers/{customerId}/tax-profiles/{taxProfileId}:activateReactivatecustomer.write
GET/api/v1/garages/{garageId}/vehicles?customerId=&query=&includeInactive=&page=&pageSize=Search vehicles by plate (with or without separators), make or model; filter by customercustomer.read
POST/api/v1/garages/{garageId}/vehiclesRegister { customerId, licensePlate, make, model, year, vin, engineNumber }customer.write
GET / PATCH/api/v1/garages/{garageId}/vehicles/{vehicleId}Vehicle detail (with isActive, editable and lockedFields[]) / update. editable is false while a live quote, repair order or settlement refers to the vehicle (a deleted quote does not count), and lockedFields then names all six identity fields (LICENSE_PLATE, VIN, ENGINE_NUMBER, MAKE, MODEL, YEAR). Changing one of them fails 409 VEHICLE_FIELD_LOCKED (writing the value it already has is fine). The owner is never changed here: a customerId that differs is VALIDATION_ERROR and an update without a mask leaves it alonecustomer.read / customer.write
POST/api/v1/garages/{garageId}/vehicles/{vehicleId}:correctCorrect one identity field even while it is locked { field, value, reason } (FR-CUS-09). reason is required (at most 2000 characters), value is the new value (the year as digits; blank clears an optional field), a plate already registered is 409 ALREADY_EXISTS. Audited as vehicle.corrected with the identity before and after and the reason; documents keep what they snapshottedcustomer.correct
POST/api/v1/garages/{garageId}/vehicles/{vehicleId}:change-ownerMove the vehicle to another customer of the garage { customerId, reason } (FR-CUS-09); reason is optional, the same owner is VALIDATION_ERROR, an unknown customer 404. The vehicle’s history stays with it. Audited as vehicle.owner_changedcustomer.correct
DELETE/api/v1/garages/{garageId}/vehicles/{vehicleId}?dryRun=Delete or deactivate the vehicle (Section 1.5): a vehicle that quotes or repair orders refer to keeps its history and is deactivatedcustomer.write
POST/api/v1/garages/{garageId}/vehicles/{vehicleId}:activateReactivate a deactivated vehiclecustomer.write
GET/api/v1/garages/{garageId}/suppliers?query=&includeInactive=&page=&pageSize=The garage’s suppliers (FR-SUP-01); query matches name, tax code or contact. Replaces /partners; insurers are not suppliers and return in Phase 17supplier.read
POST/api/v1/garages/{garageId}/suppliersCreate { name, taxCode, contact { contactPerson, phone, email, address }, bank { bankName, accountNumber, accountHolder }, note }; the name is unique per garage, case-insensitively (409 ALREADY_EXISTS)supplier.write
GET / PATCH/api/v1/garages/{garageId}/suppliers/{supplierId}Supplier detail / update; contact and bank replace the whole blocksupplier.read / supplier.write
DELETE/api/v1/garages/{garageId}/suppliers/{supplierId}?dryRun=Delete or deactivate the supplier (Section 1.5)supplier.write
POST/api/v1/garages/{garageId}/suppliers/{supplierId}:activateReactivate a deactivated suppliersupplier.write

| GET | /api/v1/garages/{garageId}/vehicles/{vehicleId}/history | Quotes and repair orders (Release 1.2–1.3) — settlements join in Release 1.4 — newest first, for the vehicle repair history (FR-CUS-03) | customer.read | | POST | /api/v1/garages/{garageId}/customers/{customerId}/data-requests | Personal-data request (FR-CUS-05, Phase 13) | customer.write |

6. Files

Since Release 5.0 every upload is registered in the garage’s file registry (Database Design 6.7). A file belongs to an entity of the garage, its kind decides whether it is public or private, and business records keep the object key or the file id, never a URL.

KindVisibilityContentLimitEntityPermission
FILE_KIND_LOGOPublicPNG, JPEG, WebP1 MBthe garagegarage.write
FILE_KIND_PART_IMAGE (Release 6.0)PublicPNG, JPEG, WebPset in internal/file/domain, resized on uploada part (attached with Section 8.1)catalogue.write

Release 5.2 adds two private kinds, payment_evidence (a plan-payment proof owned by the owner user: PNG, JPEG, WebP or PDF, 5 MB) and platform_qr (the platform’s payment QR owned by the admin who uploaded it: PNG, JPEG or WebP, 1 MB); their rules are in Database Design 6.7. Every later kind (repair photos, claim documents, exports) is private and is served only through short-lived signed URLs.

MethodPathDescriptionAccess
POST/api/v1/garages/{garageId}/filesRegister and store a file { kind, entityType, entityId, content (base64), contentType } → 201 with the file { id, kind, entityType, entityId, contentType, size, sha256, url, createdAt } (url only for public kinds). The kind’s rules decide size, content types and permission; an empty, oversized or unsupported file is VALIDATION_ERROR, and an entity that is not the garage’s is refused. A kind that its own module uploads (the logo, through PUT …/logo) is refused herepermission of the owning module
GET/api/v1/garages/{garageId}/files/{fileId}/url{ url, expiresAt }: the public URL, or for a private file a signed URL valid for 5 minutesread of the owning module
DELETE/api/v1/garages/{garageId}/files/{fileId}?dryRun=Delete or deactivate the file (Section 1.5); a file a business row refers to is deactivatedwrite of the owning module
GET/api/v1/files/{key}Public files when storage is local disk (local, staging); private objects are served at /api/v1/files/_private/{key}?expires=&signature= (HMAC of key and expiry, STORAGE_SIGNING_KEY). In production the URLs point to Cloud Storage: public objects in the public bucket, private ones as V4 signed URLs of the private bucket (STORAGE_GCS_PRIVATE_BUCKET)Public / signed

7. Quote & Repair Order API

7.1. Quotes (Release 1.2)

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/quotes?customerId=&vehicleId=&status=&page=&pageSize=List quotes, newest firstquote.read
POST/api/v1/garages/{garageId}/quotesCreate a draft { customerId, vehicleId, approvalMode, notes, lines[] } (counts against maxTransactionsPerDay, FR-LIC-02); approvalMode defaults to the garage’s defaultQuoteApprovalMode (FR-TEN-05) when omittedquote.write
GET/api/v1/garages/{garageId}/quotes/{quoteId}Quote detail with its linesquote.read
POST/api/v1/garages/{garageId}/quotes/{quoteId}/lines:fromJobAppend a catalogue job’s lines to a draft quote { jobId } (FR-CAT-06, Release 7.3); returns the quote. One labor line at the job’s laborPrice, then a line per suggested part whose part is active, at the part’s price in the suggested unit; hidden consumables (nonBillable) are priced 0. An inactive job: JOB_INACTIVE (409); a quote that is not a draft, or one that would pass 100 lines, is refusedquote.write
PATCH/api/v1/garages/{garageId}/quotes/{quoteId}Update a draft; lines[] replaces the whole set. Fails FAILED_PRECONDITION once the quote is no longer a draftquote.write
DELETE/api/v1/garages/{garageId}/quotes/{quoteId}Delete a draftquote.write
POST/api/v1/garages/{garageId}/quotes/{quoteId}/sendingSend for customer approval (FR-QUO-02): for LINK mode, generates a fresh approval token, e-mails it automatically when the customer has an e-mail on file, and returns { publicUrl, emailed } for the advisor to copy or confirm; for MANUAL mode, only marks the quote SENT, ready for the decision endpoint belowquote.write
POST/api/v1/garages/{garageId}/quotes/{quoteId}/decisionRecord the customer’s decision on their behalf { decisions: [{ lineId, approved }] } — one entry per line (FR-QUO-03): the normal path for MANUAL quotes, or a staff override of a LINK one (e.g. the customer decided by phone)quote.write
POST/api/v1/garages/{garageId}/quotes/{quoteId}/conversionConvert an approved quote into a draft repair order (FR-QUO-04), preserving only the approved lines; returns { quote, repairOrderId }. Counts separately against maxTransactionsPerDayquote.write

Quote.billingParty (FR-CUS-07) is who the quote is billed to, a snapshot of one of the customer’s tax profiles: { taxProfileId, kind, legalName, taxId, address, invoiceEmail }, absent for a retail receipt. A client sends only billingParty.taxProfileId: omitting billingParty on create takes the customer’s default profile (retail when there is none), an empty taxProfileId is a retail receipt on purpose, and a profile that is not an active profile of the quote’s customer is VALIDATION_ERROR. On update the party changes only when the update mask names billing_party (an update of the lines never resets it) or when the customer changes (the new customer’s default applies). The snapshot is carried to the repair order by conversion and starts the settlement.

A quote’s lifecycle is DRAFT → SENT → APPROVED | REJECTED → CONVERTED (APPROVED once at least one line is approved, else REJECTED). QuoteLine.itemType is LABOR or PART; quantity is a decimal string (e.g. "1.5"), unitPrice/lineTotal integer VND.

A quote line also carries jobId (the job it came from) and nonBillable (Release 7.3, FR-CAT-08). A non-billable line is a part line that refers to a catalogue part; its unitPrice and lineTotal are 0 and it is left out of totalAmount. Staff see it marked; the public quote, the PDF and the e-mail omit it, and the decision lists the billable lines only (a decision naming a hidden line is INVALID_ARGUMENT). A quote needs at least one billable line.

The LINK approval mode’s public link — {appBaseUrl}/public/quotes/{token} in the owner web — calls these endpoints with no Authorization header; the opaque token in the path is the authorization. The link stops working once decided, or after 30 days.

MethodPathDescriptionAccess
GET/api/v1/public/quotes/{token}The quote’s number, garage name, customer/vehicle names, lines and total — no internal idsPublic
POST/api/v1/public/quotes/{token}/decisionThe customer’s own decision { decisions: [{ lineId, approved }] }, same shape and rule (one entry per line) as the staff-side decision endpointPublic

An unknown, expired or already-decided token answers 422 QUOTE_LINK_INVALID on either endpoint.

7.3. Repair Orders (Release 1.3)

Release 1.2’s ConvertQuote already creates the row (repair_orders/repair_order_lines, status open) that these endpoints manage; CreateRepairOrder below is the second, ad-hoc way to create one (FR-REP-01), with no quote involved.

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/repair-orders?vehicleId=&status=&technicianId=&page=&pageSize=List repair orders (the Kanban board’s data); technicianId narrows to orders with a line assigned to that technicianrepair_order.read
POST/api/v1/garages/{garageId}/repair-ordersCreate an ad-hoc repair order { vehicleId, lines[] } (counts against maxTransactionsPerDay, FR-LIC-02); numbered from the garage’s repairOrderNumberTemplate (FR-TEN-05)repair_order.write
GET/api/v1/garages/{garageId}/repair-orders/{orderId}Repair order detail with its linesrepair_order.read
POST/api/v1/garages/{garageId}/repair-orders/{orderId}/linesAdd a line { itemType, itemRefId?, description, quantity | unitId + enteredQuantity, unitPrice, jobId?, nonBillable? } to an order that is not closed or cancelled (FR-REP-07, Release 7.3); returns the order. A part line reaches the draft stock issue; refused with STOCK_ISSUE_POSTED (409) once that is posted. At most 100 linesrepair_order.write
PATCH/api/v1/garages/{garageId}/repair-orders/{orderId}/lines/{lineId}Assign technicians, update one line’s own progress { status, assignedTechnicianIds } (FR-REP-02/03) and, from Release 7.3, edit the line itself: description, itemRefId, quantity, unitId, enteredQuantity, unitPrice named in the update mask (FR-REP-07). A line in a packaging unit takes its amount from enteredQuantity (only quantity is INVALID_ARGUMENT); a part, quantity or unit change is refused with STOCK_ISSUE_POSTED once the issue is posted; returns the whole repair order, whose overall status is re-derived from its linesrepair_order.write
DELETE/api/v1/garages/{garageId}/repair-orders/{orderId}/lines/{lineId}Remove a line from an order that is not closed or cancelled (FR-REP-07); returns the order. The last line is refused (LAST_LINE, 409), as is leaving only hidden consumables, and a part line once the stock issue is posted (STOCK_ISSUE_POSTED)repair_order.write
POST/api/v1/garages/{garageId}/repair-orders/{orderId}/cancellationCancel an order that is OPEN or WAITING_ON_PARTS with no started line (FR-REP-08, Release 7.3); returns the order with status CANCELLED, publishes repair_order.cancelled, which cancels the draft stock issue and releases the reservation. The quote stays converted. REPAIR_ORDER_NOT_CANCELLABLE (409) once work has started or the order is completed, closed or already cancelled; STOCK_ISSUE_POSTED once the issue is postedrepair_order.write
POST/api/v1/garages/{garageId}/repair-orders/{orderId}/photosUpload before/after photos (FR-REP-04, Phase 11)repair_order.write
POST/api/v1/garages/{garageId}/repair-orders/{orderId}/closingClose the repair order (FR-REP-06): fails FAILED_PRECONDITION unless every line is completed; publishes repair_order.closed, which posts the order’s stock issue and so deducts stock for its part lines (FR-INV-02, FR-INV-20, Event Design)repair_order.write

RepairOrder.status is one of OPEN (“To Do”) / IN_PROGRESS / WAITING_ON_PARTS (blocked on a part not currently in stock; set from Release 7.3 while the order’s draft stock issue cannot be covered, and cleared by itself when it can) / COMPLETED (every line done, ready to close) / CLOSED / CANCELLED (Release 7.3) — the Kanban board’s five columns (UI Design Spec 3.3), derived from the lines’ own RepairOrderLine.status (NOT_STARTED / IN_PROGRESS / COMPLETED) rather than set directly, except CLOSED which only the closing endpoint sets. customerName/vehicleLicensePlate are denormalized onto the repair order at creation, the same way Quote does, so this module never queries Customer’s tables directly. A PART line’s itemRefId is the id of a part of the Catalogue (validated against the garage’s parts when set; the field keeps its name). A part line with no itemRefId is free text: it is not reserved and not deducted on close. A part line that names a part also gets a line on its stock issue (Section 8.5).

8. Catalogue & Inventory API (parts since Release 1.3; units and catalogue Release 6.0)

Two modules serve this section (HLD Section 3.1). Catalogue answers “what is this part?”: the parts, their units and packaging, categories, brands, prices and images (8.1). Inventory answers “how many are there, and what happened to them?”: the stock on hand and movements (8.2), goods receipts (8.3), part suppliers and cost reports (8.4) and stock issues (8.5). Catalogue is served from the vietgara.catalogue.v1 contract and Inventory from vietgara.inventory.v1; the paths below do not change if the modules ever become separate services. Catalogue endpoints need catalogue.read / catalogue.write (new in Release 6.0); Inventory endpoints keep inventory.read / inventory.write.

8.1. Parts, units, categories and images (Catalogue)

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/parts?query=&categoryId=&brand=&includeInactive=&page=&pageSize=List the garage’s parts; query matches SKU, name or brand; categoryId also matches the descendants of that category. No quantities (see 8.2)catalogue.read
POST/api/v1/garages/{garageId}/partsAdd a part { sku, name, baseUnitId, salePrice, categoryId?, brand?, reorderThreshold? }; baseUnitId is required (VALIDATION_ERROR when missing, NOT_FOUND when the unit does not exist in the garage; the same for categoryId)catalogue.write
GET/api/v1/garages/{garageId}/parts/{partId}Part detail with its base unit, packaging units, category and imagescatalogue.read
PATCH/api/v1/garages/{garageId}/parts/{partId}Update the given fields (update mask). baseUnitId cannot change once the part has stock movements, packaging units or document lines (FAILED_PRECONDITION)catalogue.write
DELETE/api/v1/garages/{garageId}/parts/{partId}?dryRun=Delete or deactivate the part (Section 1.5): a part that stock transactions or documents refer to is deactivated, one used nowhere is soft-deletedcatalogue.write
POST/api/v1/garages/{garageId}/parts/{partId}:activateReactivate a deactivated partcatalogue.write
GET/api/v1/garages/{garageId}/units?includeInactive=The garage’s units of measure (FR-CAT-01)catalogue.read
POST/api/v1/garages/{garageId}/unitsCreate { name, abbreviation }; the name is unique per garage, ignoring letter case (409 ALREADY_EXISTS)catalogue.write
PATCH/api/v1/garages/{garageId}/units/{unitId}Update the given fields (update mask), including isActivecatalogue.write
DELETE/api/v1/garages/{garageId}/units/{unitId}?dryRun=Delete or deactivate (Section 1.5): a unit that a part, a stock transaction or a document line refers to is deactivatedcatalogue.write
PUT/api/v1/garages/{garageId}/parts/{partId}/unitsReplace the part’s packaging table { packagingUnits: [{ unitId, factor, salePrice? }] } (FR-CAT-02); returns the part. factor is a decimal string above zero, base units per packaging unit. Refused: the base unit as a packaging unit, a unit twice, an inactive or unknown unitcatalogue.write
PUT/api/v1/garages/{garageId}/parts/{partId}/barcodesReplace the part’s barcodes { barcodes: [{ barcode, unitId? }] } (FR-INV-13); returns the part. At most 20; each is trimmed, 1 to 64 letters, digits, spaces or - . / + $ % (BARCODE_INVALID, 422), unique among the garage’s parts without regard to case (BARCODE_ALREADY_USED, 409). unitId names one of the part’s packaging units; empty is the base unit. PUT …/units is refused (PACKAGING_UNIT_HAS_BARCODE, 409) when it would remove a unit that has a barcodecatalogue.write
GET/api/v1/garages/{garageId}/barcodes:lookup?barcode=The part a scanned code belongs to: { part, barcode }, where barcode carries the unitId one scan stands for. Case-insensitive; an inactive part is found (its isActive is false) so the screen can say so; NOT_FOUND (404) when no part has the codecatalogue.read
GET/api/v1/garages/{garageId}/part-categories?includeInactive=The category tree as a flat list with parentId (FR-CAT-03)catalogue.read
POST/api/v1/garages/{garageId}/part-categoriesCreate { name, parentId? }; refused beyond three levels (VALIDATION_ERROR), the name is unique among siblingscatalogue.write
PATCH/api/v1/garages/{garageId}/part-categories/{categoryId}Rename, move under another parent (same depth rule, no cycle) or set isActivecatalogue.write
DELETE/api/v1/garages/{garageId}/part-categories/{categoryId}?dryRun=Delete or deactivate; a category with live children or parts is deactivated, never deletedcatalogue.write
POST/api/v1/garages/{garageId}/parts/{partId}/imagesAttach an uploaded part_image file { fileId }, at most three per part (FR-CAT-04); the file must have been uploaded for this part (kind=PART_IMAGE, entityType=part, entityId the part) and not be attached elsewherecatalogue.write
DELETE/api/v1/garages/{garageId}/parts/{partId}/images/{fileId}Detach an imagecatalogue.write

Parts in answers. A part (the Part message of vietgara.catalogue.v1, formerly Item) carries baseUnitId with baseUnitName and baseUnitAbbreviation, salePrice (VND per base unit), categoryId with categoryName, brand, isActive, packagingUnits[] ({ unitId, unitName, unitAbbreviation, factor, salePrice, salePriceOverridden }, where salePrice is the override or base price × factor and salePriceOverridden says which; on input only unitId, factor and an optional override salePrice are read) and images[] ({ fileId, url } in display order; the part_image kind is public, so url loads without a signature). reorderThreshold is a decimal string in the base unit; a part carries no quantity (Section 8.2). The free-text unit of earlier releases no longer exists; the Phase 2 apps only read quantities, which are unchanged.

Quantities in units. A goods receipt line takes enteredQuantity typed in an optional unitId (a packaging unit of that part; the base unit or none means the base unit) and unitCost per that unit (Section 8.3). A quote or repair-order part line takes unitId together with enteredQuantity (required with it; only on a part line that refers to a part). The backend converts with exact decimal arithmetic (base = entered × factor); a result with more than three decimals is refused, never rounded, and a unit that is not one of the part’s units is refused (VALIDATION_ERROR). On a line the stored quantity is always derived from enteredQuantity: a quantity sent with a unitId is ignored, so a line read back and sent again cannot drift. Answers keep quantity as the base amount and add unitId, enteredQuantity and unitAbbreviation, so a document prints what was typed (240 (10 case)). A receipt line’s unitCost is per the unit typed and is stored per base unit (cost ÷ factor, up to four decimals). Details: Database Design 5.9.

Prices. A quote part line prefilled from a part uses salePrice (or the packaging unit’s price); the line keeps its own copy, so changing the part’s price never changes an existing quote. salePrice is visible to anyone with catalogue.read (which the default roles of advisors, accountants, technicians and inventory staff hold; quote.write alone does not grant it, because the part picker needs it); cost fields need inventory.cost.read (Release 7.0).

Excel. GET …/reports/stock-movements/excel shows both the base unit and the entered unit; GET …/reports/revenue/excel?period=&from=&to= exports the figures the dashboard shows (report.read).

8.2. Stock on hand and movements (Inventory)

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/stock-levels?query=&categoryId=&brand=&lowStock=&includeInactive=&page=&pageSize=The stock-on-hand list: one row per part with partId, partSku, partName, base unit, category, brand, thumbnailUrl, isActive, quantityOnHand, reorderThreshold, lowStock, quantityReserved and quantityAvailable (base unit, decimal strings). query, categoryId and brand are answered by Catalogue; lowStock=true and the quantities by Inventory (HLD Section 3.1). With inventory.cost.read, averageCost and stockValue are added. Ordered by part nameinventory.read
GET/api/v1/garages/{garageId}/stock-levels/{partId}The stock level of one partinventory.read
GET/api/v1/garages/{garageId}/reports/stock-movements?partId=&type=&page=&pageSize=Periodic stock in/out report (FR-INV-05), newest first, and the stock card of a part with partId. Each movement carries partId, partSku, partName, type, quantity, refType, refId, refNumber, reason, createdByName and quantityAfter (Release 7.1), and with inventory.cost.read also unitCost and supplierIdinventory.read

The old GET …/inventory-items and GET …/inventory-items/{partId} paths are renamed stock-levels (Release 7.0), and the JSON field itemId is partId everywhere. quantityOnHand is output-only, a decimal string derived from stock_balance. StockTransaction.type is IN (a completed goods receipt) or OUT (a posted stock issue, or the reversal of a cancelled goods receipt). refType is GOODS_RECEIPT or STOCK_ISSUE with refId the document’s id; PURCHASE and REPAIR_ORDER are the movements recorded before Release 7.0. From Release 7.1 the adjustments have their own types: STOCK_COUNT (a completed count, or the reversal of a cancelled one), WRITE_OFF and SUPPLIER_RETURN carry their document’s number in refNumber; REPAIR_ORDER_RETURN (a returned repair order part) has no document number, its reason is the return’s reason. The type (IN or OUT) follows the change of stock. A movement caused by a user has createdByName; an automatic one has none. Posting a stock issue writes each part’s movement at most once: a redelivered repair_order.closed event, or a second post of the same issue, is a no-op, not a double deduction.

Creating, editing and deleting a part are Catalogue operations (/parts, Section 8.1). POST /stock-receipts, the single-part receipt of Release 1.3, is removed in Release 7.0: a receipt is a goods receipt document (Section 8.3).

8.3. Goods receipts (Release 7.0, FR-INV-06)

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/goods-receipts?query=&supplierId=&status=&page=&pageSize=List goods receipts, newest first. query matches the number, the delivery note number or the notes; supplierId keeps the receipts with a line from that supplier; status is DRAFT, COMPLETED or CANCELLEDinventory.read
POST/api/v1/garages/{garageId}/goods-receiptsCreate a draft { defaultSupplierId?, receiptDate, deliveryNoteNumber, notes, lines[] }; each line { partId, unitId?, enteredQuantity, unitCost?, supplierId? }. The number comes from the garage’s numbering.goodsReceipt template (FR-TEN-05). A line without supplierId takes the header’s defaultSupplierId, then the part’s default supplier (Section 8.4)inventory.write
GET/api/v1/garages/{garageId}/goods-receipts/{receiptId}Receipt detail with its linesinventory.read
PATCH/api/v1/garages/{garageId}/goods-receipts/{receiptId}Update the given fields (update mask, filled from the body when omitted). Only a draft can change (GOODS_RECEIPT_NOT_DRAFT, 409). lines replaces the whole list: a line with an id is kept and updated, a line without one is added, and a line left out is removedinventory.write
POST/api/v1/garages/{garageId}/goods-receipts/{receiptId}:completeComplete a draft: one stock movement per line, the part’s average cost and last cost per supplier updated. Refused with SUPPLIER_REQUIRED (409) while a line has no supplier, and with GOODS_RECEIPT_NOT_DRAFT (409) unless it is a draftinventory.write
POST/api/v1/garages/{garageId}/goods-receipts/{receiptId}:cancelA draft is closed and no stock moves. A completed receipt is reversed: its movements are reversed and the previous quantity and average cost restored; refused with INSUFFICIENT_STOCK_TO_REVERSE (409) when that would take a part below zero, and audited. A cancelled receipt cannot be cancelled again (GOODS_RECEIPT_ALREADY_CANCELLED, 409)inventory.write
GET/api/v1/garages/{garageId}/goods-receipts/{receiptId}/pdfThe receipt as a PDF (FR-DOC-01)inventory.read

A receipt answers number, status, defaultSupplierId with defaultSupplierName, receiptDate (YYYY-MM-DD in the garage’s time zone), deliveryNoteNumber, notes, lines[], completedAt and cancelledAt. Each line answers partSku, partName, baseUnitAbbreviation, unitId (unset for the base unit), unitAbbreviation, enteredQuantity, quantity (the base amount, derived on save), supplierId with supplierName, and unitCost per entered unit as typed. With inventory.cost.read the receipt also answers totalAmount (the sum of the lines’ amounts, whole VND) and each line its lineAmount; without it, the cost fields are omitted and unitCost cannot be sent. Quantities follow the rules of Section 8.1.

8.4. Part suppliers and cost reports (Release 7.0, FR-INV-07, FR-INV-19)

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/parts/{partId}/suppliersThe part’s usual suppliers: supplierId, supplierName, supplierSku (the supplier’s own code), isDefault and lastUnitCost (whole VND per base unit, omitted when never paid; with inventory.cost.read only)inventory.read
PUT/api/v1/garages/{garageId}/parts/{partId}/suppliersReplace the part’s supplier list { data: [{ supplierId, supplierSku, isDefault }] }. At most one default (422 otherwise). Returns the new listinventory.write
GET/api/v1/garages/{garageId}/reports/purchases?supplierId=&from=&to=Purchases by supplier and period, from completed receipts: one line per receipt line with the receipt number and date, the supplier, the part, the base-unit quantity, unitCost and amount, plus totalAmount. from is inclusive and to exclusive (YYYY-MM-DD, garage time zone)inventory.cost.read
GET/api/v1/garages/{garageId}/reports/purchases/excel?supplierId=&from=&to=The purchases report as an Excel workbook (FR-DOC-02); same filtersinventory.cost.read
GET/api/v1/garages/{garageId}/reports/stock-valuation?at=Quantity and value per part at a date (YYYY-MM-DD, empty means today), with the categories’ totals and totalValue. Values use the average cost on that dateinventory.cost.read
GET/api/v1/garages/{garageId}/reports/stock-valuation/excel?at=The valuation as an Excel workbook (FR-DOC-02)inventory.cost.read

Without inventory.cost.read the two reports are refused as a whole with 403 (PERMISSION_DENIED); their cost fields are not omitted, because the report has nothing else to show.

8.5. Stock issues (Release 7.0, FR-INV-20)

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/repair-orders/{orderId}/stock-issueThe order’s stock issue document: number (from the garage’s numbering.stockIssue template), status, repairOrderNumber, isShort (Release 7.3: the stock on hand cannot cover the draft), lines[] with partId, partSku, partName, quantity, shortQuantity (what the stock cannot cover yet, “0” when covered), unitCost (cost permission) and returnedQuantity (Release 7.1: what came back to stock from the line, FR-INV-11), and postedAt and postedByName once posted. Created with the order; 404 when the order has noneinventory.read
POST/api/v1/garages/{garageId}/stock-issues/{issueId}:postPost the issue now (“issue now”): the stock movements are written and the reservation consumed. STOCK_ISSUE_NOT_DRAFT (409) once posted. Otherwise repair_order.closed (or repair_order.started with stockIssueMode = ON_START) posts itinventory.write
GET/api/v1/garages/{garageId}/repair-orders/{orderId}/stock-issue/pdfThe stock issue (phiếu xuất kho) as a PDF (FR-DOC-01)inventory.read

A stock issue has no cancel action of its own: its draft follows the order. Adding, changing or removing a part line of the order re-syncs the draft (repair_order.lines_changed), and cancelling the order cancels it and releases the reservation (repair_order.cancelled), both from Release 7.3. A posted issue is read-only.

8.6. Stock counts (Release 7.1, FR-INV-12)

Reads need inventory.read; every change and the completion need inventory.count. Only a draft accepts changes (STOCK_COUNT_NOT_DRAFT, 409).

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/stock-counts?query=&status=&page=&pageSize=List counts, newest first. query matches the number, name or notes; status is DRAFT, COMPLETED or CANCELLED. A list answer carries the tally (uncountedLines, matchedLines, overLines, shortLines) but no lines[]inventory.read
POST/api/v1/garages/{garageId}/stock-countsCreate an empty draft { name, notes }. The number comes from the garage’s numbering.stockCount template (FR-TEN-05); lines are added with the next callinventory.count
GET/api/v1/garages/{garageId}/stock-counts/{stockCountId}Count detail with its linesinventory.read
PATCH/api/v1/garages/{garageId}/stock-counts/{stockCountId}Update name and notes (updateMask, filled from the body when omitted)inventory.count
POST/api/v1/garages/{garageId}/stock-counts/{stockCountId}/linesAdd parts to a draft: { partIds[], categoryId? }. Each part takes its system quantity now. A part already on the count is skipped. categoryId adds every active part of that category and its sub-categories that has a stock balance or a reservationinventory.count
PATCH/api/v1/garages/{garageId}/stock-counts/{stockCountId}/lines/{lineId}Type the counted quantity: { unitId?, enteredCountedQuantity }, in the packaging unit or the base unit. An empty enteredCountedQuantity marks the line uncounted; "0" is a real count of none. The system quantity is taken again at each typinginventory.count
DELETE/api/v1/garages/{garageId}/stock-counts/{stockCountId}/lines/{lineId}Remove a line from a draftinventory.count
POST/api/v1/garages/{garageId}/stock-counts:mergeMerge at least two drafts: { stockCountIds[] }. The first is kept and returned; the others are cancelled with no lines and the note “Merged into {number}”. A part on several drafts keeps one line whose counted quantity is the sum, in the base unit. Fewer than two ids is VALIDATION_ERROR (422); a draft that is not a draft is STOCK_COUNT_NOT_DRAFT (409)inventory.count
POST/api/v1/garages/{garageId}/stock-counts/{stockCountId}:completeComplete a draft: { skipUncounted }. One adjustment per differing line, so that stock equals the count, valued at the average cost of the moment; the count becomes read-only. Refused with STOCK_COUNT_HAS_UNCOUNTED_LINES (409) while a line is uncounted, unless skipUncounted is true (the uncounted lines are then dropped); a count with no lines is VALIDATION_ERROR (422)inventory.count
POST/api/v1/garages/{garageId}/stock-counts/{stockCountId}:cancelA draft is closed and no stock moves. A completed count is reversed: its adjustments are reversed at their cost. Refused with INSUFFICIENT_STOCK_TO_REVERSE (409) when the stock they added has since been used. A cancelled count is STOCK_COUNT_ALREADY_CANCELLED (409)inventory.count
GET/api/v1/garages/{garageId}/stock-counts/{stockCountId}/excelThe count as an Excel workbook for review (FR-DOC-02): each line with its system and counted quantities, result and difference. The value columns (average cost, difference value) are included only with inventory.cost.readinventory.read

A count answers number, status, name, notes, lines[], the tally, totalDifferenceValue (sum of the lines’ values, whole VND, signed; cost only), completedAt, completedByName, cancelledAt, createdByName, createdAt and updatedAt. Each line answers partId, partSku, partName, baseUnitAbbreviation, unitId (unset for the base unit), unitAbbreviation, enteredCountedQuantity, countedQuantity (base unit, derived; empty while uncounted), systemQuantity (base unit, snapshot), differenceQuantity (counted minus system, base unit, signed), result (UNCOUNTED, MATCHED, OVER or SHORT), countedAt, and with inventory.cost.read averageCost and differenceValue (signed; a shortage is negative). Quantities follow Section 8.1.

8.7. Write-offs (Release 7.1, FR-INV-09)

Reads need inventory.read; create and cancel need inventory.adjust. A write-off is posted when it is created.

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/write-offs?query=&reason=&status=&page=&pageSize=List write-offs, newest first. query matches the number or notes; reason is DAMAGED, LOST, EXPIRED, INTERNAL_USE or WARRANTY_REPLACEMENT; status is COMPLETED or CANCELLEDinventory.read
POST/api/v1/garages/{garageId}/write-offsCreate and post { reason, notes, lines[] }; each line { partId, unitId?, enteredQuantity }, a positive quantity. The number comes from numbering.writeOff. Refused with INSUFFICIENT_STOCK (409) when a line asks for more than is on handinventory.adjust
GET/api/v1/garages/{garageId}/write-offs/{writeOffId}Write-off detail with its linesinventory.read
POST/api/v1/garages/{garageId}/write-offs/{writeOffId}:cancelCancel: the parts go back to stock at the cost each line left with. A cancelled write-off is WRITE_OFF_ALREADY_CANCELLED (409)inventory.adjust

A write-off answers number, status, reason, notes, lines[] (partId, partSku, partName, baseUnitAbbreviation, unitId, unitAbbreviation, enteredQuantity, quantity, and with inventory.cost.read unitCost and lineValue), totalValue (cost only), cancelledAt, createdByName and createdAt. Its stock movements carry refType WRITE_OFF, the number as refNumber and the reason.

8.8. Returns to supplier (Release 7.1, FR-INV-10)

Reads need inventory.read; create and cancel need inventory.adjust. A return is posted when it is created.

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/supplier-returns?query=&supplierId=&status=&page=&pageSize=List returns, newest first. query matches the number, the credit note number or the notesinventory.read
POST/api/v1/garages/{garageId}/supplier-returnsCreate and post { supplierId, goodsReceiptId?, creditNoteNumber?, notes, lines[] }; each line { partId, unitId?, enteredQuantity, unitCost? }. The number comes from numbering.supplierReturn. A line without unitCost takes the cost of the same part on the linked receipt, else the part’s average cost. Without inventory.cost.read a typed unitCost is ignored and the default applies. Refused with INSUFFICIENT_STOCK (409) when a line is more than on hand; GOODS_RECEIPT_NOT_COMPLETED (409) when the linked receipt is not completed; VALIDATION_ERROR (422) when the supplier is not one of the garage’s or the linked receipt has no line from that supplierinventory.adjust
GET/api/v1/garages/{garageId}/supplier-returns/{supplierReturnId}Return detail with its linesinventory.read
POST/api/v1/garages/{garageId}/supplier-returns/{supplierReturnId}:cancelCancel: the parts go back to stock at their cost. A cancelled return is SUPPLIER_RETURN_ALREADY_CANCELLED (409)inventory.adjust

A return answers number, status, supplierId, supplierName, goodsReceiptId and goodsReceiptNumber (when linked), creditNoteNumber, notes, lines[] (with unitCost and lineAmount for a caller with inventory.cost.read), creditAmount (the sum of the lines’ amounts, whole VND, each line rounded half-up; cost only), cancelledAt, createdByName and createdAt. creditNoteNumber is set when the return is created and has no update call.

8.9. Return of unused repair order parts (Release 7.1, FR-INV-11)

The part return is made against a repair order whose stock issue is posted. It needs inventory.adjust (not repair_order.write); reads need inventory.read.

MethodPathDescriptionAccess
POST/api/v1/garages/{garageId}/repair-orders/{repairOrderId}/part-returnsReturn parts of one part line: { repairOrderLineId, unitId?, enteredQuantity, reason }. reason is required and at most 500 characters. Refused with STOCK_ISSUE_NOT_POSTED (409) until the order’s stock issue is posted; RETURN_EXCEEDS_ISSUED (409) when the total returned from that line would pass what was issued on it; 404 when the line is not on the orderinventory.adjust
GET/api/v1/garages/{garageId}/repair-orders/{repairOrderId}/part-returnsThe order’s returns. Records are immutable: there is no update or deleteinventory.read

A return answers repairOrderNumber, stockIssueId, repairOrderLineId, partId, partSku, partName, quantity (base unit), enteredQuantity, unitId, unitAbbreviation, reason, unitCost (the line’s cost snapshot, restored as it is; cost only), createdByName and createdAt. The stock issue of the order answers returnedQuantity per line, so the remaining quantity is the issued quantity minus it. The return posts a REPAIR_ORDER_RETURN movement (IN) and does not change the order’s lines or what the customer is billed.

8.10. Excel import (Release 7.2, FR-INV-14)

Three types: IMPORT_TYPE_PARTS, IMPORT_TYPE_GOODS_RECEIPTS and IMPORT_TYPE_STOCK_COUNTS. Any active member of the garage may call the three endpoints; what a type needs is checked against the caller and the file (below).

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/imports/{importType}/templateThe template workbook: a Data sheet with only the header row, and an Instructions sheet explaining every column in English and Vietnamese. Answers the raw .xlsxthe type’s permission
POST/api/v1/garages/{garageId}/imports/{importType}/preview{ fileName?, content }, content the .xlsx as base64. Checks the file and answers an ImportPreview; writes nothingthe type’s permission
POST/api/v1/garages/{garageId}/imports/{importType}/confirmThe same body. Checks again and imports the whole file in one transaction. Answers an ImportResultthe type’s permission

Permissions. Parts: catalogue.write, plus inventory.write when a row has an opening quantity and inventory.cost.read when it has a cost. Goods receipts: inventory.write (costs need inventory.cost.read). Stock counts: inventory.count. A caller without the type’s permission is told with a row-0 PERMISSION_REQUIRED error in the preview and PERMISSION_DENIED (403, reason PERMISSION_REQUIRED) on confirm; a column the caller may not use is a row-0 error too.

Templates (the Data sheet headers).

TypeColumns
PartsSKU, Name, Base unit, Base unit abbreviation, Category, Brand, Barcodes, Packaging units, Opening quantity, Unit cost, Supplier
Goods receiptReceipt date, Delivery note, Notes, SKU, Quantity, Unit, Unit cost, Supplier
Stock countName, Notes, SKU, Counted quantity, Unit

Barcodes is Code;Code@Unit;… and Packaging units is Name=Factor;…. In the receipt and count files SKU is a SKU or a barcode. Header names are matched without regard to case and spacing; extra columns are ignored; blank rows are skipped. SKU and barcode cells are read as text, so leading zeros survive.

ImportPreview answers importType, totalRows, validRows, errors[] (row as the sheet shows it, so the header is row 1 and the first data row is 2, column, code, message; row 0 is a problem of the whole file), errorsTruncated, columns[], sampleRows[] (the first 20), counts and canConfirm (rows exist and no error at all). At most 1,000 errors are listed. Codes include FILE_EMPTY, FILE_TOO_LARGE, TOO_MANY_ROWS, INVALID_FILE, MISSING_COLUMN, PERMISSION_REQUIRED, REQUIRED_VALUE_MISSING, INVALID_VALUE, SKU_DUPLICATE, SKU_ALREADY_EXISTS, UNKNOWN_SKU, PART_INACTIVE, BARCODE_INVALID, BARCODE_ALREADY_USED, INVALID_QUANTITY, INVALID_COST, INVALID_DATE, UNKNOWN_SUPPLIER, SUPPLIER_REQUIRED, UNKNOWN_UNIT, UNIT_NOT_FOR_PART, UNIT_MISMATCH, INVALID_UNIT, INVALID_PACKAGING_UNIT, INVALID_CATEGORY and HEADER_MISMATCH.

ImportResult answers importType, rowsImported, counts and the created document: documentId and documentNumber (the completed opening-stock receipt of a parts file, the draft receipt or the draft count; empty when a parts file had no opening quantity). counts keys: parts parts, categories, units, barcodes, receipt_lines; documents lines.

Refusals. Confirming a file that has any error is IMPORT_FILE_INVALID (422) and writes nothing. Confirming the same content again for the same garage and type is IMPORT_FILE_ALREADY_IMPORTED (409). Limits: 5 MB, 5,000 data rows; a REST body of an import route may be up to 7.5 MiB (base64), other routes 6 MiB.

9. Billing & Settlement API (Release 1.4; payment vouchers Release 5.0)

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/settlements?status=&outstandingOnly=&page=&pageSize=List settlements, newest first. outstandingOnly=true is FR-REC-01’s receivables view (status PENDING or PARTIALLY_PAID, every customer); ignored when status is also setbilling.read
POST/api/v1/garages/{garageId}/settlementsCreate a settlement from a closed repair order { repairOrderId, billingParty, invoiceRequested } (FR-BIL-01; counts against maxTransactionsPerDay, FR-LIC-02) → 201. Requires an Idempotency-Key (Section 1.3). Fails FAILED_PRECONDITION unless the repair order is closed, ALREADY_EXISTS if it already has one. Numbered from the garage’s settlementNumberTemplate (FR-TEN-05); copies the garage’s legal identity (snapshot). billingParty defaults to the repair order’s (the quote’s); an ad-hoc repair order takes the customer’s default profile. invoiceRequested: true with an incomplete party is 409 INVOICE_DATA_INCOMPLETE, checked before anything is consumedbilling.write
GET/api/v1/garages/{garageId}/settlements/{settlementId}Settlement detail with its payment vouchers, voided ones included, its billingParty and invoiceRequestedbilling.read
PATCH/api/v1/garages/{garageId}/settlements/{settlementId}Change who is billed and whether a VAT invoice is requested { billingParty { taxProfileId }, invoiceRequested } (FR-CUS-07); only these two fields can change, named by the update mask (both when it is omitted). billingParty.taxProfileId is resolved as for a quote (empty = retail). Fails 409 INVOICE_DATA_INCOMPLETE when an invoice is requested and the party lacks a legal name, tax ID or address (a retail receipt always does). Audited as settlement.billing_updatedbilling.write
POST/api/v1/garages/{garageId}/settlements/{settlementId}/paymentsRecord a payment actually collected { amount, method, note, payerName, payerAddress, reason } (FR-BIL-03) and issue its payment voucher (phiếu thu, FR-BIL-05) → the settlement. Requires an Idempotency-Key (Section 1.3): a retried payment is recorded once. amount must be positive and at most what is still due (VALIDATION_ERROR). The voucher is numbered from the garage’s paymentVoucherNumberTemplate; payerName defaults to the billing party’s legal name, else the customer’s name, payerAddress to the party’s address, and reason to “Thanh toán quyết toán {settlement number}”; the signed-in user is the collector and the garage’s legal identity is copied onto the voucher. A settlement may receive several payments until the total is collectedbilling.write
POST/api/v1/garages/{garageId}/settlements/{settlementId}/payments/{paymentId}:voidVoid a voucher recorded by mistake { reason } (FR-BIL-06; required, at most 500 characters) → the settlement. The voucher stays on file with voided, voidReason and voidedAt, the settlement’s paidAmount and status are recalculated, and the audit log records who voided it. A voucher already voided is 409 ALREADY_EXISTS, an unknown payment 404 NOT_FOUNDbilling.write

Settlement.status (PENDING / PARTIALLY_PAID / PAID) is derived from paidAmount, the payments that are not voided, against the total — a zero-total settlement is immediately PAID. A settlement has one payer, the customer: Release 5.0 removes the customer/insurer allocation of FR-BIL-02 (payerType); it returns with insurance in Phase 17. Each entry of payments carries voucherNumber, payerName, payerAddress, reason, collectorName, method, amount, note, createdAt and, once voided, voided, voidReason, voidedAt. customerName/vehicleLicensePlate are denormalized from the repair order at creation, the same pattern Quote and Repair Order already use. PaymentMethod.ONLINE is recorded manually — staff sees a bank app or gateway dashboard confirmation and records it; automatic confirmation via a payment gateway webhook is Release 16.0 (FR-PAY-02, Section 11). There is no separate receivables table (Database Design 6.1); one arrives with Release 13.0’s reminders and reconciliation, which need their own persisted state.

10. Document/Print API (Release 1.4)

Every document is rendered fresh from the owning module’s own data on each request — no templates are stored (FR-DOC-03, letting a garage design its templates, is Release 14.2; Release 10.0 adds built-in A4/A5 templates behind a print model). Each endpoint needs the permission of the data it renders, not a permission of its own, and the response is the raw file (Content-Type: application/pdf or the Excel MIME type), not a JSON envelope.

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/quotes/{quoteId}/pdfThe quote as a PDF (FR-DOC-01)quote.read
GET/api/v1/garages/{garageId}/repair-orders/{repairOrderId}/pdfThe repair order as a PDF (FR-DOC-01)repair_order.read
GET/api/v1/garages/{garageId}/settlements/{settlementId}/receipt-pdfThe settlement as a receipt PDF (FR-DOC-01)billing.read
GET/api/v1/garages/{garageId}/settlements/{settlementId}/payments/{paymentId}/voucher-pdfOne payment voucher (phiếu thu, FR-BIL-05) as a PDF in the 01-TT layout of Circular 200/2014/TT-BTC: the garage’s legal header, payer, reason, the amount in figures and in words (Vietnamese), and signature blocks with the collector under “Thủ quỹ”. It prints the legal identity the voucher was issued with, and a voided voucher is stamped “ĐÃ HỦY” with its reasonbilling.read
GET/api/v1/garages/{garageId}/goods-receipts/{receiptId}/pdfThe goods receipt as a PDF (FR-INV-06, FR-DOC-01)inventory.read
GET/api/v1/garages/{garageId}/repair-orders/{orderId}/stock-issue/pdfThe stock issue (phiếu xuất kho) as a PDF (FR-INV-20, FR-DOC-01)inventory.read
GET/api/v1/garages/{garageId}/reports/purchases/excel?supplierId=&from=&to=The purchases by supplier as an Excel workbook (FR-INV-19, FR-DOC-02)inventory.cost.read
GET/api/v1/garages/{garageId}/reports/stock-valuation/excel?at=The stock valuation as an Excel workbook (FR-INV-07, FR-DOC-02)inventory.cost.read
GET/api/v1/garages/{garageId}/reports/stock-movements/excel?partId=The stock movement report as an Excel workbook (FR-DOC-02); partId is the only filter (no type, unlike Section 8’s JSON report) and every matching transaction is included, not one paginated screeninventory.read

Every PDF embeds a TrueType font (DejaVu Sans), so Vietnamese diacritics print in garage names, customer names and descriptions. A quote, repair order or settlement prints the legal identity it was issued with (FR-TEN-06): changing the garage’s legal profile later changes only the documents issued afterwards. The logo is drawn from the snapshot’s object key; one that cannot be read is left out.

FR-DOC-02 also lists revenue and receivables Excel exports; those wait for the reports that produce that data to exist — revenue (Reporting, Section 13) arrives in Release 6.0 and receivables with Release 13.0’s receivables model (Section 11).

11. Receivables (Phase 13) & Payment (Phase 16) API (planned)

MethodPathDescription
GET/api/v1/garages/{garageId}/receivablesList of receivables (reminders, reconciliation) — supersedes Section 9’s outstandingOnly filter once this module’s own persisted state exists (Release 13.0)
POST/api/v1/garages/{garageId}/settlements/{settlementId}/payment-linksCreate an online payment link (VNPay/Momo/ZaloPay)
POST/api/v1/webhooks/payments/{provider}Payment confirmation webhook (signature-verified)

12. E-Invoice API (Phase 16, planned) and Insurance API (Phase 17, planned)

MethodPathDescription
PUT/api/v1/garages/{garageId}/einvoice-connectionConnect an e-invoice provider account (Release 16.1)
POST/api/v1/garages/{garageId}/settlements/{settlementId}/invoicesIssue an e-invoice from a settlement
GET/api/v1/garages/{garageId}/invoices/{invoiceId}Invoice status

The insurance endpoints (claims, events, document checklist, settlement split) are in Section 16.14.

13. Reporting API

13.1. Revenue Report (Release 1.5, FR-RPT-01)

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/reports/revenue?period=&from=&to=Revenue by period for one garage. period is REVENUE_PERIOD_DAY | _MONTH | _QUARTER | _YEAR; from/to are YYYY-MM-DD (from inclusive, to exclusive). Returns { points: [{ periodLabel, amount }], totalAmount }, periodLabel already formatted for the requested granularity ("2026-09-30" / "2026-09" / "2026-Q3" / "2026")report.read

Read fresh from Billing & Settlement’s settlements on every request (summed by created_at truncated to period) — Reporting owns no tables of its own, the same stateless pattern as Document/Print (Section 10). The Owner Web Dashboard’s other KPIs (orders in progress, low stock, outstanding receivables) are composed from each owning module’s own existing endpoint, not served here.

Excel (Release 6.0, FR-DOC-02). GET /api/v1/garages/{garageId}/reports/revenue/excel?period=&from=&to= takes the same parameters (period is the same RevenuePeriod value) and returns the same figures as an .xlsx file, produced by the Document/Print service from this report (report.read).

13.2. Platform Analytics (Release 12.4, planned)

For platform admins only (FR-ADM-01..03). Aggregate counts, never a garage’s business data. period buckets by DAY / MONTH / YEAR over [from, to); each answer is { points: [{ periodLabel, … }], totals }.

MethodPathDescriptionAccess
GET/api/v1/admin/stats/signups?period=&from=&to=New users, verified users and new garages per bucket (FR-ADM-01)Admin
GET/api/v1/admin/stats/licenses?period=&from=&to=New trials, trial-to-paid conversions, upgrades/downgrades, past due/suspended/cancelled per bucket, and active subscriptions by plan (FR-ADM-02)Admin
GET/api/v1/admin/stats/activity?period=&from=&to=Active garages and platform-wide transactions per bucket (FR-ADM-03)Admin

13.3. Multi-Garage Dashboard (Release 15.2, planned)

MethodPathDescription
GET/api/v1/me/reports/consolidatedConsolidated multi-garage dashboard for the owner (FR-RPT-03)

14. Mobile BFF API (Phase 2)

The Technician and Customer apps each call one consolidated API (HLD Section 6). Both BFFs own no tables. Each call reads across the caller’s garages, switching the Row-Level Security scope to one garage at a time.

14.1. Technician BFF (Release 2.2, FR-TAPP-01)

MethodPathDescriptionAccess
GET/api/v1/technician/workEvery open (not closed) repair order with a line assigned to the caller, across the active garages where they may read repair orders, oldest first. Each WorkOrder has garageId, garageName, repairOrderId, number, status, vehicleLicensePlate, customerName, createdAt, and lines: only the caller’s own lines. Answer: {data: [WorkOrder]}Verified

A verified user on no garage’s staff gets an empty list. Staff join garages through the invitations of Section 3.2.

14.2. Customer BFF (Release 2.4, FR-CAPP-02)

The caller is a VietGara user, created by POST /auth/google/id-token. Their customer records are the garages’ customer profiles linked to them (FR-CUS-06). A profile whose email equals the user’s verified e-mail (ignoring case) is linked on the first call through a SECURITY DEFINER lookup that returns ids only, unless it has ever had a link with this user (a refusal or unlink is respected) or is linked to someone else; a link request still waiting is answered in the app, not assumed.

MethodPathDescriptionAccess
GET/api/v1/customer/vehiclesOne MyGarage per customer record: garageId, name, address, phone, customerId, customerName and vehicles (the Vehicle of Section 5), sorted by garage name. Answer: {data: [MyGarage]}Verified
GET/api/v1/customer/garages/{garageId}/vehicles/{vehicleId}/historyThe vehicle’s history entries, as in Section 5, newest first. Draft quotes are left out. NOT_FOUND unless the vehicle belongs to one of the caller’s customer records in that garageVerified

Garage links (Release 5.1, FR-CAPP-05). Each GarageLink is { id, garageId, garageName, garageAddress, garagePhone, customerId, customerName, status, requestedAt, linkedAt }; the caller sees only their own links.

MethodPathDescriptionAccess
GET/api/v1/me/garage-linksLink requests waiting for an answer and the garages already linked, newest request first. Answer: {data: [GarageLink]}. Suppliers never appearVerified
POST/api/v1/me/garage-links/{linkId}:acceptAccept a waiting request → the link, LINKED through channel APP. 404 for a link that is not the caller’s, 409 FAILED_PRECONDITION for one already answeredVerified
POST/api/v1/me/garage-links/{linkId}:declineTurn a waiting request down → DECLINED; the e-mail match never links that profile againVerified
POST/api/v1/me/garage-links/{linkId}:unlinkEnd an established link → UNLINKED. 409 FAILED_PRECONDITION unless it is linkedVerified

15. Examples

15.1. Create a Customer — POST /api/v1/garages/{garageId}/customers

POST /api/v1/garages/9b2f…/customers
Authorization: Bearer eyJ…
Content-Type: application/json
{ "fullName": "Nguyễn Văn An", "phone": "0901234567", "personalDataConsent": true }
HTTP/1.1 201 Created
{
"id": "c1a7…", "garageId": "9b2f…", "fullName": "Nguyễn Văn An", "phone": "0901234567",
"email": "", "address": "", "personalDataConsent": true,
"personalDataConsentAt": "2026-09-26T02:15:00Z", "vehicleCount": 0,
"createdAt": "2026-09-26T02:15:00Z", "updatedAt": "2026-09-26T02:15:00Z"
}

15.2. Plan limit reached — POST /api/v1/garages

HTTP/1.1 402 Payment Required
{
"error": {
"code": "PLAN_LIMIT_REACHED",
"message": "your plan allows at most 1 for max_garages; upgrade to add more",
"details": []
}
}

15.3. Validation error

HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "VALIDATION_ERROR",
"message": "numbering template is invalid",
"details": [{ "field": "numbering.repairOrder", "issue": "invalid" }]
}
}

16. Planned Endpoints by Release (Phases 3–17)

Paths follow the conventions of Section 1.1. Access names are permission modules of the RBAC catalogue (FR-IAM-03) or of the platform catalogue (FR-IAM-10).

16.1. Phase 3: terminology

No new endpoints. account becomes user in every field and message: the session answers { accessToken, refreshToken, tokenType, expiresIn, user }, ids are userId, and events carry user_id. There is no dual account field (development mode).

The endpoints are in Section 1.5 (delete-or-deactivate), 3.1 (legal profile, logo, numbering), 5 (customers, vehicles, suppliers), 6 (files and signed URLs), 9 (payments, vouchers, :void) and 10 (voucher PDF). The payment request takes no payerType, and its answer carries voucherNumber on each payment.

Left for later releases:

MethodPathDescriptionAccess
POST…/{id}:activateOn every later deactivatable resource (units 6.0, tax profiles 5.1, …); today customers, vehicles and suppliersmodule write
POST/api/v1/garages/{garageId}/filesUpload for each later private kind (repair photos in Phase 11, claim documents in Phase 17); the logo is the only kind today and is uploaded through the garage endpointmodule write

Voucher issue and void are recorded in the audit log (settlement.payment_recorded, settlement.payment_voided); payment_voucher.issued and payment_voucher.voided events wait for their first consumer (Event Design Section 4).

16.3. Release 5.1 and 5.2: customers, tax profiles, vehicle correction and plan payments

Release 5.2’s plan payment requests, review queue and platform payment details are in Section 4. The Release 5.1 endpoints are in Section 5 (user links, tax profiles, vehicle lock, :correct, :change-owner), 7.1 (billingParty on quotes), 9 (PATCH of a settlement, invoiceRequested) and 14.2 (garage links). Phone matching of a link request waits for verified phones with phone sign-in (Release 2.4); until then a request matches by e-mail only.

16.4. Release 6.0: units and catalogue

Specified in Section 8.1 (Catalogue: parts, units, categories, images, with the quantity and price rules) and 8.2 (Inventory: stock and movements); the revenue Excel export is in Section 13.

16.5. Release 6.1: MFA

MethodPathDescriptionAccess
POST/api/v1/auth/mfa/challengeExchange the challenge token and a TOTP or recovery code for the sessionPublic (challenge token)
GET/api/v1/me/mfaWhether MFA is on, since when, and how many recovery codes are leftVerified
POST/api/v1/me/mfa/enrollStart enrolment → secret and otpauthUri (the client draws the QR code)Verified
POST/api/v1/me/mfa/enroll:confirmConfirm with a code, turn MFA on → recovery codes (shown once)Verified
POST/api/v1/me/mfa/recovery-codesReplace the recovery codes (needs a TOTP code)Verified
POST/api/v1/me/mfa:disableTurn MFA off (needs a TOTP or recovery code)Verified

MFA is optional and applies to e-mail and password sign-in only. When the user has it on, POST /auth/login answers a session with mfaRequired: true, a single-use challengeToken (5 minutes) and challengeExpiresAt, and no tokens. User.mfaEnabled tells the clients whether it is on.

Errors: MFA_CODE_INVALID (422), MFA_LOCKED (429, five wrong codes lock the user’s code checks for 15 minutes), MFA_CHALLENGE_INVALID (401), and MFA_ALREADY_ENABLED, MFA_NOT_ENABLED, MFA_ENROLLMENT_NOT_STARTED, MFA_NOT_AVAILABLE (a Google-only user) and MFA_NOT_CONFIGURED (409).

16.6. Phase 7: inventory operations

Release 7.0 is specified in Sections 8.2 to 8.5 and its documents in Section 10. Release 7.1 (stock counts, write-offs, returns to supplier and repair order part returns) is specified in Sections 8.6 to 8.9, and Release 7.2 (barcodes and Excel import) in Sections 8.1 and 8.10. Nothing of Phase 7 is planned any more except Release 7.3 (Section 16.8).

Cost fields are omitted from answers when the caller lacks inventory.cost.read (FR-INV-08), as from Release 7.0.

16.7. Phase 8: platform staff and support access

MethodPathDescriptionAccess
GET/POST/PATCH/DELETE/api/v1/admin/platform-roles[/{id}]Platform roles and their permissions (FR-IAM-10)Admin platform_staff
GET/POST/PATCH/DELETE/api/v1/admin/platform-groups[/{id}] and .../membersPlatform staff groups and membersAdmin platform_staff
POST/api/v1/admin/platform-invitationsInvite a user into a platform staff groupAdmin platform_staff
GET/api/v1/admin/me/permissionsThe platform permissions of the caller (menus in the admin web and app)Any platform user
POST/api/v1/admin/support-grantsRequest read-only access to a garage { garageId, reason, ttl } (FR-IAM-11); a Platform Admin’s grant is active at once and notifies the ownerAdmin support
POST/api/v1/garages/{garageId}/support-grants/{grantId}:approve / :revokeThe owner approves or revokes a grantOwner
GET/api/v1/garages/{garageId}/support-access-logGrants and entities opened by supportOwner

All /api/v1/admin/... endpoints (Sections 4 and 13.2) check a platform permission instead of “is a platform admin”. A support session reads garage data through the normal read endpoints with the grant’s token; every write is refused.

16.8. Release 7.3: jobs and reservation; Release 10.0: printing

Release 7.3 (implemented):

MethodPathDescriptionAccess
GET/api/v1/garages/{garageId}/jobs?query=&category=&includeInactive=&page=&pageSize=List jobs (FR-CAT-05), by category then name; query matches the name or the category. Inactive jobs are left out unless asked forquote.read
POST/api/v1/garages/{garageId}/jobsCreate a job { name, category, laborPrice, durationMinutes, parts: [{ partId, quantity, unitId?, nonBillable? }] }. name is unique in the garage without regard to case (ALREADY_EXISTS, 409); laborPrice and durationMinutes are not negative; at most 30 parts, each once; each part exists (NOT_FOUND) and is active (FAILED_PRECONDITION); quantity is above zero with at most 3 decimals; unitId is a packaging unit of that part (empty is the base unit). The answer describes each part: partSku, partName, unitAbbreviation, unitPrice (the part’s current price per unitId or base unit) and partIsActivequote.write
GET/api/v1/garages/{garageId}/jobs/{jobId}One job with its suggested partsquote.read
PATCH/api/v1/garages/{garageId}/jobs/{jobId}Update the fields in the update mask (name, category, laborPrice, durationMinutes, parts, isActive); parts replaces the whole listquote.write
DELETE/api/v1/garages/{garageId}/jobs/{jobId}?dryRun=Delete-or-deactivate (FR-CAT-07): a job that a quote or repair order line came from is deactivated, any other deleted (its name and its parts’ packaging units freed); dryRun answers whichquote.write
POST/api/v1/garages/{garageId}/jobs/{jobId}:activateReactivate a deactivated jobquote.write
POST/api/v1/garages/{garageId}/quotes/{quoteId}/lines:fromJobAdd a job’s labor and part lines (FR-CAT-06): see Section 7.1quote.write

A part that a live job suggests is deactivated, not deleted, by DELETE …/parts/{id}, and its base unit is locked. PUT …/parts/{id}/units is refused (PACKAGING_UNIT_USED_BY_JOB, 409) when it would remove a packaging unit a live job suggests.

Stock levels answer quantityOnHand, quantityReserved (the draft stock issues, from Release 7.0) and quantityAvailable (FR-INV-15). From Release 7.3 a repair order whose draft stock issue the stock cannot cover is WAITING_ON_PARTS (Section 7.2), and the issue answers isShort and a shortQuantity per line (Section 8.5).

Release 10.0 (planned):

MethodPathDescriptionAccess
GET…/{document}/pdf?paper=A4|A5&template=Existing PDF endpoints gain paper and template (FR-DOC-04); defaults come from the garage settingsper document
PUT/api/v1/garages/{garageId}/print-settingsDefault template and paper size per document typegarage.write
POST/api/v1/garages/{garageId}/{document}/{id}/print-logRecord that a document was printed (FR-DOC-05)per document

16.9. Phase 12: booking

MethodPathDescriptionAccess
GET/PUT/api/v1/garages/{garageId}/booking-settingsHours, capacity, lead time, cancellation window, auto-confirm, slug (FR-BKG-01)booking.write
GET/POST/PATCH/DELETE/api/v1/garages/{garageId}/bays[/{id}]Bays (Release 12.3, FR-BKG-09)booking.write
GET/api/v1/garages/{garageId}/bookings?from=&to=&status=Calendarbooking.read
POST/api/v1/garages/{garageId}/bookingsStaff-created bookingbooking.write
POST/api/v1/garages/{garageId}/bookings/{id}:confirm / :reject / :arrived / :no-show / :convertLifecycle actions; :convert creates a quote or repair order (FR-BKG-07)booking.write
GET/api/v1/public/garages/{slug}/slots?from=&to=&jobIds=Free slots, no customer data (FR-BKG-02, 04); rate limitedPublic
POST/api/v1/public/garages/{slug}/bookingsPublic booking with description and photos (FR-BKG-10), held 15 minutes until the e-mail OTP is verifiedPublic
POST/api/v1/public/bookings/{id}:verifyVerify the OTP → requestedPublic
GET/api/v1/public/owners/{ownerSlug}/garagesOwner-level page listing the garages (FR-BKG-11)Public
GET/POST/api/v1/me/bookings[/{id}:reschedule | :cancel]Customer BFF: own bookings (FR-BKG-03)Verified

16.10. Phase 13: receivables, payables, insights

Receivables and payment-out vouchers (/receivables, /payment-out-vouchers, reconciliation and receivables Excel), purchase orders (/purchase-orders, FR-INV-16), supplier payables (/supplier-payables, FR-INV-17), technician report (GET .../reports/technicians), profit report (GET .../reports/profit?period=&groupBy=job\|technician\|repair-order, FR-RPT-05, needs inventory.cost.read), Google linking (POST /api/v1/me/google-link, DELETE) and data requests (Section 5).

16.11. Phase 14: printers, agent, templates

Garage printers (/printers), print agents (/print-agents, a WebSocket /api/v1/agent/connect authenticated by the agent key), barcode labels (GET .../inventory-items/{id}/label?size=), and templates: GET/POST/PATCH /api/v1/garages/{garageId}/print-templates[/{id}], POST .../{id}:publish (creates an immutable version), POST .../{id}:preview with sample data.

16.12. Phase 15: transfers

POST /api/v1/garages/{garageId}/stock-transfers (ship), POST .../stock-transfers/{id}:receive (at the receiving garage, FR-INV-18), :cancel with reversal.

16.13. Phase 16: payments, e-invoices, messages

Payment links and webhooks (Section 11), e-invoice connection and issue (Section 12), SMS and Zalo channel settings per garage (PUT .../notification-channels). A subscription payment request (Section 4) is then charged to the owner’s saved payment method and confirmed by the webhook instead of being declared and reviewed by hand; its quote, ledger and period snapshot stay as they are.

16.14. Phase 17: insurance

MethodPathDescription
GET/POST/PATCH/DELETE/api/v1/garages/{garageId}/insurers[/{id}]The garage’s insurers directory (FR-INS-07); not customers, not suppliers
POST/api/v1/garages/{garageId}/repair-orders/{orderId}/claimsCreate a claim (FR-INS-01)
GET/PATCH/api/v1/garages/{garageId}/claims/{claimId}Detail with timeline and deadlines; status updates (FR-INS-02, 04)
PUT/api/v1/garages/{garageId}/claims/{claimId}/agreed-quoteMark the quote version the insurer agreed (FR-INS-03)
POST/api/v1/garages/{garageId}/claims/{claimId}/documentsAttach a document to a checklist item
POST/api/v1/garages/{garageId}/claims/{claimId}/settlement-splitSplit the settlement into the deductible and the insurer’s part (FR-INS-05)
POST/api/v1/garages/{garageId}/claims/{claimId}:submitPackage the file, or submit by API (FR-INS-06)
POST/api/v1/me/claims/{claimId}/documentsCustomer BFF: the vehicle owner uploads a document

16.15. Release 17.2: supplier portal

MethodPathDescriptionAccess
POST/api/v1/garages/{garageId}/suppliers/{supplierId}/user-linksSend a link request to the supplier’s user (FR-SUP-02)supplier.write
DELETE/api/v1/garages/{garageId}/suppliers/{supplierId}/user-links/currentUnlinksupplier.write
GET/api/v1/me/supplier-linksSupplier portal: link requests and the garages the user suppliesVerified
POST/api/v1/me/supplier-links/{linkId}:accept / :declineAccept or declineVerified
GET/POST/api/v1/me/supplier-links/{linkId}/purchase-orders[/{id}:confirm | :decline | /attachments]Purchase orders addressed to the supplier; confirm or decline lines; attach a delivery note or invoice (FR-SUP-03)Verified, linked

The portal is a separate surface from the Customer App and sees only documents addressed to the linked supplier.

16.16. Release 10.2: contact request

MethodPathDescriptionAccess
POST/api/v1/public/contact-requestsStores a contact or demo request from the company website and e-mails support (FR-WEB-02, FR-WEB-04). Body: name, garageName, phone, email, message, consent (must be true), locale (vi | en). Returns 202 with the request id. Rate limited per source address (strict profile)Public, server to server

The request is sent by the website’s own server-side function, not by a browser, and carries the anti-spam check’s result in the function; the endpoint itself does not render a page or set a cookie. It is not part of the mobile BFFs and reads nothing back.