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
| Topic | Convention |
|---|---|
| Base path | /api/v1/… (major version in the path) |
| Hosts | The 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) |
| Resources | Plural 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) |
| JSON | camelCase 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 |
| Read | GET /resources (list), GET /resources/{id} |
| Create | POST /resources with the resource as the body → 201 Created with the resource |
| Update | PATCH /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 |
| Delete | DELETE /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 |
| Actions | A 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-mail | A 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/filter | Query parameters: query (free text), and typed filters such as status, type, customerId |
| Authentication | Authorization: Bearer <access token> on every endpoint except sign-up, sign-in, token refresh and password reset |
| Language | Errors carry stable codes; the UI translates them. E-mails use the user’s locale (LOCALE_VI default, LOCALE_EN) |
| Idempotency | Creation 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, …).
| HTTP | When | Typical codes |
|---|---|---|
| 400 | Malformed request | BAD_REQUEST |
| 401 | Missing/invalid token or credentials | UNAUTHENTICATED, INVALID_CREDENTIALS, REFRESH_TOKEN_INVALID |
| 402 | The owner’s plan blocks the action | SUBSCRIPTION_SUSPENDED, PLAN_LIMIT_REACHED |
| 403 | Authenticated but not allowed | EMAIL_NOT_VERIFIED, PERMISSION_DENIED, GARAGE_ACCESS_DENIED, OWNER_ONLY, SUBSCRIPTION_READ_ONLY, USER_SUSPENDED |
| 404 | Not found (or not visible to the caller) | NOT_FOUND, USER_NOT_FOUND |
| 409 | Conflict with the current state | EMAIL_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) |
| 422 | Semantically invalid input | VALIDATION_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 |
| 429 | Too many attempts | OTP_RESEND_TOO_SOON, OTP_TOO_MANY_ATTEMPTS, RATE_LIMITED |
| 500 | Unexpected failure | INTERNAL |
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:
| Case | Answer |
|---|---|
No Idempotency-Key, or not 1–255 printable ASCII characters | 422 IDEMPOTENCY_KEY_REQUIRED; nothing happens |
| A key not seen before | The 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 request | The 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 endpoint | 422 IDEMPOTENCY_KEY_REUSED; nothing happens |
| A key whose request failed | Nothing 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):
| Level | Meaning |
|---|---|
| Public | No token |
| Signed in | Any valid token, even if the e-mail is not verified yet |
| Verified | A verified user |
perm | A verified user that owns the garage or is an active member holding permission perm there (e.g. customer.read) |
| Member | Any active member of the garage |
| Owner | The garage owner |
| Admin | A 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):
| Call | Answer |
|---|---|
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=true | The same outcome without changing anything, so a client can word its confirmation (“this will deactivate the customer”) |
POST …/{id}:activate | Reactivates a deactivated object and answers it with isActive: true (customers, vehicles, suppliers) |
GET a list with includeInactive=true | Also 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
| Method | Path | Description | Access |
|---|---|---|---|
| POST | /api/v1/auth/register | Register 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/login | Sign in with email + password; returns a session | Public |
| GET | /api/v1/auth/google/login | Starts Google sign-in (browser redirect, opened in a popup) | Public |
| GET | /api/v1/auth/google/callback | Google redirect target; posts {type, session | error} to the opener window | Public |
| POST | /api/v1/auth/google/id-token | Mobile 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/refresh | Exchange refreshToken for a new session (the refresh token rotates) | Public |
| POST | /api/v1/auth/logout | Revoke refreshToken | Public |
| POST | /api/v1/auth/email-verification/otp | E-mail a new verification OTP; returns {expiresAt, resendAvailableAt} | Signed in |
| POST | /api/v1/auth/email-verification | Verify with otpCode and the current refreshToken; returns a new session whose token is verified | Signed in |
| POST | /api/v1/auth/password-reset/otp | E-mail a reset OTP to email (same answer whether or not the user exists) | Public |
| POST | /api/v1/auth/password-reset/otp/verification | Check email + otpCode; returns a short-lived resetToken | Public |
| POST | /api/v1/auth/password-reset | Set newPassword with resetToken; revokes all sessions | Public |
| GET | /api/v1/me | The signed-in user | Signed in |
| PATCH | /api/v1/me | Update fullName, phone, locale | Signed 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
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/garages | Garages the caller owns or works in, each with access { owner, permissions[] } | Verified |
| POST | /api/v1/garages | Create a garage (counts against maxGarages); default staff groups are created | Verified |
| GET | /api/v1/garages/{garageId} | Garage details | Member |
| 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}/logo | Upload 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 theirs | garage.write |
| DELETE | /api/v1/garages/{garageId}/logo | Remove the logo; returns the garage | garage.write |
3.2. Staff and Invitations
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/garages/{garageId}/staff | Members (owner first) with status, groupIds, effective permissions | staff.read |
| PATCH | /api/v1/garages/{garageId}/staff/{staffId} | Change status (STAFF_STATUS_ACTIVE / STAFF_STATUS_INACTIVE) and/or groupIds | staff.write |
| DELETE | /api/v1/garages/{garageId}/staff/{staffId} | Remove a member | staff.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 invitations | staff.read |
| POST | /api/v1/garages/{garageId}/invitations | Invite userId with groupIds (counts against maxStaff); e-mails the invitee | staff.write |
| POST | /api/v1/garages/{garageId}/invitations/{invitationId}/revoke | Revoke a pending invitation | staff.write |
| GET | /api/v1/me/invitations?status= | Invitations to the caller | Verified |
| GET | /api/v1/me/invitations/{invitationId} | One invitation (the e-mail links to it) | Verified |
| POST | /api/v1/me/invitations/{invitationId}/accept | Accept: joins the garage and its groups | Verified |
| POST | /api/v1/me/invitations/{invitationId}/decline | Decline | Verified |
3.3. Roles and Staff Groups
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/permissions | The permission catalogue in display order: { permission, module, action }, action being read or write | Verified |
| GET | /api/v1/roles | The 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/roles | Create { name, description, permissions[] } | Verified |
| GET | /api/v1/roles/{roleId} | One role | Verified |
| PATCH | /api/v1/roles/{roleId} | Update name, description, permissions; permissions[] replaces the role’s whole set | Verified (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-groups | The garage’s groups with role and member count | staff.read |
| POST | /api/v1/garages/{garageId}/staff-groups | Create { name, description, roleId } | Owner |
| PATCH | /api/v1/garages/{garageId}/staff-groups/{groupId} | Update | Owner |
| DELETE | /api/v1/garages/{garageId}/staff-groups/{groupId} | Delete | Owner |
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
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/plans | Plans an owner can choose, with price, billingCycle, limits { maxGarages, maxStaff, maxTransactionsPerDay }, features[] | Signed in |
| GET | /api/v1/me/subscription | The 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-requests | Start 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 approval | Verified |
| GET | /api/v1/me/subscription/payment-requests?status= | The caller’s requests, newest first | Verified |
| GET | /api/v1/me/subscription/payment-requests/{requestId} | Request detail, with the evidence’s signed URL (5 minutes) once submitted, and the rejection reason | Verified |
| POST | /api/v1/me/subscription/payment-requests/{requestId}:submit | Declare 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 notified | Verified |
| POST | /api/v1/me/subscription/payment-requests/{requestId}:cancel | Cancel a request that is AWAITING_PAYMENT or SUBMITTED (before review) → the request, CANCELLED; a reviewed request is 409 REQUEST_NOT_OPEN | Verified |
| GET | /api/v1/me/subscription/credit-ledger | The credit entries { kind, amount, balanceAfter, planPaymentRequestId, note, createdAt }, newest first | Verified |
| GET | /api/v1/payment-settings | The platform’s payment details shown with a request: { bankName, accountNumber, accountHolder, branch, transferInstructions, qrUrl } (qrUrl signed, 5 minutes) | Verified |
| GET | /api/v1/garages/{garageId}/entitlements | The garage owner’s entitlements: status, planCode, features, limits, readOnly, currentPeriodEnd (drives the UI banners) | Member |
| GET | /api/v1/admin/plans | Every plan, including hidden and retired ones | Admin |
| POST | /api/v1/admin/plans | Create a plan (code is immutable afterwards) | Admin |
| PATCH | /api/v1/admin/plans/{planId} | Update price, limits, features, isPublic, isActive, sortOrder | Admin |
| GET | /api/v1/admin/subscriptions?query=&status=&page=&pageSize= | Search subscriptions by owner e-mail or name; each row carries creditBalance and openRequest | Admin |
| POST | /api/v1/admin/subscriptions/{subscriptionId}/extensions | Extend 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-adjustments | Correct the account credit { amount (signed), note } → the ledger entry; the balance never goes below 0. Audited | Admin |
| 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 first | Admin |
| GET | /api/v1/admin/plan-payment-requests/{requestId} | One request with the evidence’s signed URL, the owner’s current usage and plan | Admin |
| POST | /api/v1/admin/plan-payment-requests/{requestId}:approve | Approve { 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_OPEN | Admin |
| POST | /api/v1/admin/plan-payment-requests/{requestId}:reject | Reject { reason } (required) → the request, REJECTED; the owner is e-mailed and may submit a new request | Admin |
| GET | /api/v1/admin/payment-settings | The platform payment details | Admin |
| PATCH | /api/v1/admin/payment-settings | Update bankName, accountNumber, accountHolder, branch, transferInstructions by update mask; audited | Admin |
| POST | /api/v1/admin/payment-settings/qr | Upload or replace the QR code { content (base64), contentType } (PNG, JPEG or WebP, at most 1 MB); audited | Admin |
5. Customer & Vehicle API
| Method | Path | Description | Access |
|---|---|---|---|
| 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=true | customer.read |
| POST | /api/v1/garages/{garageId}/customers | Create { 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 deactivated | customer.write |
| POST | /api/v1/garages/{garageId}/customers/{customerId}:activate | Reactivate a deactivated customer | customer.write |
| POST | /api/v1/garages/{garageId}/customers/{customerId}/user-links | Ask 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 linked | customer.write |
| POST | /api/v1/garages/{garageId}/customers/{customerId}/user-links/current:confirm | Confirm 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 waits | customer.write |
| DELETE | /api/v1/garages/{garageId}/customers/{customerId}/user-links/current | End the link, or cancel the waiting request, from the garage’s side → the link, UNLINKED; the profile stays. 404 when there is none | customer.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-profiles | Create { 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 default | customer.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 chosen | customer.write |
| POST | /api/v1/garages/{garageId}/customers/{customerId}/tax-profiles/{taxProfileId}:activate | Reactivate | customer.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 customer | customer.read |
| POST | /api/v1/garages/{garageId}/vehicles | Register { 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 alone | customer.read / customer.write |
| POST | /api/v1/garages/{garageId}/vehicles/{vehicleId}:correct | Correct 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 snapshotted | customer.correct |
| POST | /api/v1/garages/{garageId}/vehicles/{vehicleId}:change-owner | Move 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_changed | customer.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 deactivated | customer.write |
| POST | /api/v1/garages/{garageId}/vehicles/{vehicleId}:activate | Reactivate a deactivated vehicle | customer.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 17 | supplier.read |
| POST | /api/v1/garages/{garageId}/suppliers | Create { 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 block | supplier.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}:activate | Reactivate a deactivated supplier | supplier.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.
| Kind | Visibility | Content | Limit | Entity | Permission |
|---|---|---|---|---|---|
FILE_KIND_LOGO | Public | PNG, JPEG, WebP | 1 MB | the garage | garage.write |
FILE_KIND_PART_IMAGE (Release 6.0) | Public | PNG, JPEG, WebP | set in internal/file/domain, resized on upload | a 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.
| Method | Path | Description | Access |
|---|---|---|---|
| POST | /api/v1/garages/{garageId}/files | Register 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 here | permission 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 minutes | read 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 deactivated | write 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)
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/garages/{garageId}/quotes?customerId=&vehicleId=&status=&page=&pageSize= | List quotes, newest first | quote.read |
| POST | /api/v1/garages/{garageId}/quotes | Create a draft { customerId, vehicleId, approvalMode, notes, lines[] } (counts against maxTransactionsPerDay, FR-LIC-02); approvalMode defaults to the garage’s defaultQuoteApprovalMode (FR-TEN-05) when omitted | quote.write |
| GET | /api/v1/garages/{garageId}/quotes/{quoteId} | Quote detail with its lines | quote.read |
| POST | /api/v1/garages/{garageId}/quotes/{quoteId}/lines:fromJob | Append 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 refused | quote.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 draft | quote.write |
| DELETE | /api/v1/garages/{garageId}/quotes/{quoteId} | Delete a draft | quote.write |
| POST | /api/v1/garages/{garageId}/quotes/{quoteId}/sending | Send 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 below | quote.write |
| POST | /api/v1/garages/{garageId}/quotes/{quoteId}/decision | Record 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}/conversion | Convert an approved quote into a draft repair order (FR-QUO-04), preserving only the approved lines; returns { quote, repairOrderId }. Counts separately against maxTransactionsPerDay | quote.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.
7.2. Public Quote Approval Link (FR-QUO-02, no login)
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.
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/public/quotes/{token} | The quote’s number, garage name, customer/vehicle names, lines and total — no internal ids | Public |
| POST | /api/v1/public/quotes/{token}/decision | The customer’s own decision { decisions: [{ lineId, approved }] }, same shape and rule (one entry per line) as the staff-side decision endpoint | Public |
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.
| Method | Path | Description | Access |
|---|---|---|---|
| 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 technician | repair_order.read |
| POST | /api/v1/garages/{garageId}/repair-orders | Create 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 lines | repair_order.read |
| POST | /api/v1/garages/{garageId}/repair-orders/{orderId}/lines | Add 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 lines | repair_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 lines | repair_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}/cancellation | Cancel 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 posted | repair_order.write |
| POST | /api/v1/garages/{garageId}/repair-orders/{orderId}/photos | Upload before/after photos (FR-REP-04, Phase 11) | repair_order.write |
| POST | /api/v1/garages/{garageId}/repair-orders/{orderId}/closing | Close 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)
| Method | Path | Description | Access |
|---|---|---|---|
| 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}/parts | Add 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 images | catalogue.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-deleted | catalogue.write |
| POST | /api/v1/garages/{garageId}/parts/{partId}:activate | Reactivate a deactivated part | catalogue.write |
| GET | /api/v1/garages/{garageId}/units?includeInactive= | The garage’s units of measure (FR-CAT-01) | catalogue.read |
| POST | /api/v1/garages/{garageId}/units | Create { 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 isActive | catalogue.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 deactivated | catalogue.write |
| PUT | /api/v1/garages/{garageId}/parts/{partId}/units | Replace 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 unit | catalogue.write |
| PUT | /api/v1/garages/{garageId}/parts/{partId}/barcodes | Replace 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 barcode | catalogue.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 code | catalogue.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-categories | Create { name, parentId? }; refused beyond three levels (VALIDATION_ERROR), the name is unique among siblings | catalogue.write |
| PATCH | /api/v1/garages/{garageId}/part-categories/{categoryId} | Rename, move under another parent (same depth rule, no cycle) or set isActive | catalogue.write |
| DELETE | /api/v1/garages/{garageId}/part-categories/{categoryId}?dryRun= | Delete or deactivate; a category with live children or parts is deactivated, never deleted | catalogue.write |
| POST | /api/v1/garages/{garageId}/parts/{partId}/images | Attach 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 elsewhere | catalogue.write |
| DELETE | /api/v1/garages/{garageId}/parts/{partId}/images/{fileId} | Detach an image | catalogue.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)
| Method | Path | Description | Access |
|---|---|---|---|
| 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 name | inventory.read |
| GET | /api/v1/garages/{garageId}/stock-levels/{partId} | The stock level of one part | inventory.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 supplierId | inventory.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)
| Method | Path | Description | Access |
|---|---|---|---|
| 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 CANCELLED | inventory.read |
| POST | /api/v1/garages/{garageId}/goods-receipts | Create 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 lines | inventory.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 removed | inventory.write |
| POST | /api/v1/garages/{garageId}/goods-receipts/{receiptId}:complete | Complete 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 draft | inventory.write |
| POST | /api/v1/garages/{garageId}/goods-receipts/{receiptId}:cancel | A 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}/pdf | The 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)
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/garages/{garageId}/parts/{partId}/suppliers | The 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}/suppliers | Replace the part’s supplier list { data: [{ supplierId, supplierSku, isDefault }] }. At most one default (422 otherwise). Returns the new list | inventory.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 filters | inventory.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 date | inventory.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)
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/garages/{garageId}/repair-orders/{orderId}/stock-issue | The 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 none | inventory.read |
| POST | /api/v1/garages/{garageId}/stock-issues/{issueId}:post | Post 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 it | inventory.write |
| GET | /api/v1/garages/{garageId}/repair-orders/{orderId}/stock-issue/pdf | The 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).
| Method | Path | Description | Access |
|---|---|---|---|
| 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-counts | Create an empty draft { name, notes }. The number comes from the garage’s numbering.stockCount template (FR-TEN-05); lines are added with the next call | inventory.count |
| GET | /api/v1/garages/{garageId}/stock-counts/{stockCountId} | Count detail with its lines | inventory.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}/lines | Add 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 reservation | inventory.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 typing | inventory.count |
| DELETE | /api/v1/garages/{garageId}/stock-counts/{stockCountId}/lines/{lineId} | Remove a line from a draft | inventory.count |
| POST | /api/v1/garages/{garageId}/stock-counts:merge | Merge 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}:complete | Complete 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}:cancel | A 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}/excel | The 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.read | inventory.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.
| Method | Path | Description | Access |
|---|---|---|---|
| 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 CANCELLED | inventory.read |
| POST | /api/v1/garages/{garageId}/write-offs | Create 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 hand | inventory.adjust |
| GET | /api/v1/garages/{garageId}/write-offs/{writeOffId} | Write-off detail with its lines | inventory.read |
| POST | /api/v1/garages/{garageId}/write-offs/{writeOffId}:cancel | Cancel: 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.
| Method | Path | Description | Access |
|---|---|---|---|
| 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 notes | inventory.read |
| POST | /api/v1/garages/{garageId}/supplier-returns | Create 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 supplier | inventory.adjust |
| GET | /api/v1/garages/{garageId}/supplier-returns/{supplierReturnId} | Return detail with its lines | inventory.read |
| POST | /api/v1/garages/{garageId}/supplier-returns/{supplierReturnId}:cancel | Cancel: 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.
| Method | Path | Description | Access |
|---|---|---|---|
| POST | /api/v1/garages/{garageId}/repair-orders/{repairOrderId}/part-returns | Return 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 order | inventory.adjust |
| GET | /api/v1/garages/{garageId}/repair-orders/{repairOrderId}/part-returns | The order’s returns. Records are immutable: there is no update or delete | inventory.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).
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/garages/{garageId}/imports/{importType}/template | The template workbook: a Data sheet with only the header row, and an Instructions sheet explaining every column in English and Vietnamese. Answers the raw .xlsx | the 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 nothing | the type’s permission |
| POST | /api/v1/garages/{garageId}/imports/{importType}/confirm | The same body. Checks again and imports the whole file in one transaction. Answers an ImportResult | the 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).
| Type | Columns |
|---|---|
| Parts | SKU, Name, Base unit, Base unit abbreviation, Category, Brand, Barcodes, Packaging units, Opening quantity, Unit cost, Supplier |
| Goods receipt | Receipt date, Delivery note, Notes, SKU, Quantity, Unit, Unit cost, Supplier |
| Stock count | Name, 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)
| Method | Path | Description | Access |
|---|---|---|---|
| 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 set | billing.read |
| POST | /api/v1/garages/{garageId}/settlements | Create 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 consumed | billing.write |
| GET | /api/v1/garages/{garageId}/settlements/{settlementId} | Settlement detail with its payment vouchers, voided ones included, its billingParty and invoiceRequested | billing.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_updated | billing.write |
| POST | /api/v1/garages/{garageId}/settlements/{settlementId}/payments | Record 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 collected | billing.write |
| POST | /api/v1/garages/{garageId}/settlements/{settlementId}/payments/{paymentId}:void | Void 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_FOUND | billing.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.
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/garages/{garageId}/quotes/{quoteId}/pdf | The quote as a PDF (FR-DOC-01) | quote.read |
| GET | /api/v1/garages/{garageId}/repair-orders/{repairOrderId}/pdf | The repair order as a PDF (FR-DOC-01) | repair_order.read |
| GET | /api/v1/garages/{garageId}/settlements/{settlementId}/receipt-pdf | The settlement as a receipt PDF (FR-DOC-01) | billing.read |
| GET | /api/v1/garages/{garageId}/settlements/{settlementId}/payments/{paymentId}/voucher-pdf | One 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 reason | billing.read |
| GET | /api/v1/garages/{garageId}/goods-receipts/{receiptId}/pdf | The goods receipt as a PDF (FR-INV-06, FR-DOC-01) | inventory.read |
| GET | /api/v1/garages/{garageId}/repair-orders/{orderId}/stock-issue/pdf | The 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 screen | inventory.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)
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/garages/{garageId}/receivables | List 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-links | Create 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)
| Method | Path | Description |
|---|---|---|
| PUT | /api/v1/garages/{garageId}/einvoice-connection | Connect an e-invoice provider account (Release 16.1) |
| POST | /api/v1/garages/{garageId}/settlements/{settlementId}/invoices | Issue 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)
| Method | Path | Description | Access |
|---|---|---|---|
| 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 }.
| Method | Path | Description | Access |
|---|---|---|---|
| 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)
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/me/reports/consolidated | Consolidated 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)
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/technician/work | Every 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.
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/customer/vehicles | One 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}/history | The 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 garage | Verified |
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.
| Method | Path | Description | Access |
|---|---|---|---|
| GET | /api/v1/me/garage-links | Link requests waiting for an answer and the garages already linked, newest request first. Answer: {data: [GarageLink]}. Suppliers never appear | Verified |
| POST | /api/v1/me/garage-links/{linkId}:accept | Accept 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 answered | Verified |
| POST | /api/v1/me/garage-links/{linkId}:decline | Turn a waiting request down → DECLINED; the e-mail match never links that profile again | Verified |
| POST | /api/v1/me/garage-links/{linkId}:unlink | End an established link → UNLINKED. 409 FAILED_PRECONDITION unless it is linked | Verified |
15. Examples
15.1. Create a Customer — POST /api/v1/garages/{garageId}/customers
POST /api/v1/garages/9b2f…/customersAuthorization: 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).
16.2. Release 5.0: lifecycle, legal profile, vouchers, files
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:
| Method | Path | Description | Access |
|---|---|---|---|
| POST | …/{id}:activate | On every later deactivatable resource (units 6.0, tax profiles 5.1, …); today customers, vehicles and suppliers | module write |
| POST | /api/v1/garages/{garageId}/files | Upload 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 endpoint | module 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
| Method | Path | Description | Access |
|---|---|---|---|
| POST | /api/v1/auth/mfa/challenge | Exchange the challenge token and a TOTP or recovery code for the session | Public (challenge token) |
| GET | /api/v1/me/mfa | Whether MFA is on, since when, and how many recovery codes are left | Verified |
| POST | /api/v1/me/mfa/enroll | Start enrolment → secret and otpauthUri (the client draws the QR code) | Verified |
| POST | /api/v1/me/mfa/enroll:confirm | Confirm with a code, turn MFA on → recovery codes (shown once) | Verified |
| POST | /api/v1/me/mfa/recovery-codes | Replace the recovery codes (needs a TOTP code) | Verified |
| POST | /api/v1/me/mfa:disable | Turn 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
| Method | Path | Description | Access |
|---|---|---|---|
| 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 .../members | Platform staff groups and members | Admin platform_staff |
| POST | /api/v1/admin/platform-invitations | Invite a user into a platform staff group | Admin platform_staff |
| GET | /api/v1/admin/me/permissions | The platform permissions of the caller (menus in the admin web and app) | Any platform user |
| POST | /api/v1/admin/support-grants | Request read-only access to a garage { garageId, reason, ttl } (FR-IAM-11); a Platform Admin’s grant is active at once and notifies the owner | Admin support |
| POST | /api/v1/garages/{garageId}/support-grants/{grantId}:approve / :revoke | The owner approves or revokes a grant | Owner |
| GET | /api/v1/garages/{garageId}/support-access-log | Grants and entities opened by support | Owner |
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):
| Method | Path | Description | Access |
|---|---|---|---|
| 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 for | quote.read |
| POST | /api/v1/garages/{garageId}/jobs | Create 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 partIsActive | quote.write |
| GET | /api/v1/garages/{garageId}/jobs/{jobId} | One job with its suggested parts | quote.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 list | quote.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 which | quote.write |
| POST | /api/v1/garages/{garageId}/jobs/{jobId}:activate | Reactivate a deactivated job | quote.write |
| POST | /api/v1/garages/{garageId}/quotes/{quoteId}/lines:fromJob | Add a job’s labor and part lines (FR-CAT-06): see Section 7.1 | quote.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):
| Method | Path | Description | Access |
|---|---|---|---|
| GET | …/{document}/pdf?paper=A4|A5&template= | Existing PDF endpoints gain paper and template (FR-DOC-04); defaults come from the garage settings | per document |
| PUT | /api/v1/garages/{garageId}/print-settings | Default template and paper size per document type | garage.write |
| POST | /api/v1/garages/{garageId}/{document}/{id}/print-log | Record that a document was printed (FR-DOC-05) | per document |
16.9. Phase 12: booking
| Method | Path | Description | Access |
|---|---|---|---|
| GET/PUT | /api/v1/garages/{garageId}/booking-settings | Hours, 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= | Calendar | booking.read |
| POST | /api/v1/garages/{garageId}/bookings | Staff-created booking | booking.write |
| POST | /api/v1/garages/{garageId}/bookings/{id}:confirm / :reject / :arrived / :no-show / :convert | Lifecycle 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 limited | Public |
| POST | /api/v1/public/garages/{slug}/bookings | Public booking with description and photos (FR-BKG-10), held 15 minutes until the e-mail OTP is verified | Public |
| POST | /api/v1/public/bookings/{id}:verify | Verify the OTP → requested | Public |
| GET | /api/v1/public/owners/{ownerSlug}/garages | Owner-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
| Method | Path | Description |
|---|---|---|
| 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}/claims | Create 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-quote | Mark the quote version the insurer agreed (FR-INS-03) |
| POST | /api/v1/garages/{garageId}/claims/{claimId}/documents | Attach a document to a checklist item |
| POST | /api/v1/garages/{garageId}/claims/{claimId}/settlement-split | Split the settlement into the deductible and the insurer’s part (FR-INS-05) |
| POST | /api/v1/garages/{garageId}/claims/{claimId}:submit | Package the file, or submit by API (FR-INS-06) |
| POST | /api/v1/me/claims/{claimId}/documents | Customer BFF: the vehicle owner uploads a document |
16.15. Release 17.2: supplier portal
| Method | Path | Description | Access |
|---|---|---|---|
| POST | /api/v1/garages/{garageId}/suppliers/{supplierId}/user-links | Send a link request to the supplier’s user (FR-SUP-02) | supplier.write |
| DELETE | /api/v1/garages/{garageId}/suppliers/{supplierId}/user-links/current | Unlink | supplier.write |
| GET | /api/v1/me/supplier-links | Supplier portal: link requests and the garages the user supplies | Verified |
| POST | /api/v1/me/supplier-links/{linkId}:accept / :decline | Accept or decline | Verified |
| 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
| Method | Path | Description | Access |
|---|---|---|---|
| POST | /api/v1/public/contact-requests | Stores 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.