BUSINESS PROCEDURES
VietGara — Multi-Tenant SaaS Platform for Auto Garage Management & Maintenance
Version 1.1 — October 16, 2026
1. Purpose and how to read it
The Functional Requirements say what the platform does; the Low-Level Design says how it is built. This document sits between them: for each procedure that involves more than one person or system, it shows who does what, in which order, which rules apply and what happens when something goes wrong. Support, QA and the people who run the platform read it to understand a case; it also fixes the wording the screens and e-mails use.
Each procedure names its actors, its trigger, a diagram, the rules, the exceptions, the notifications and the requirements, API calls and test scenarios it belongs to. The technical sequence of Quote → Repair → Settlement is in the High-Level Design (Section 4.1); P9 describes it from the accountant’s side. P10 to P13 describe the part catalogue and the stock; each marks what works today (up to Release 6.0) and what arrives with the inventory documents of Phase 7.
| # | Procedure | Actors | Requirements |
|---|---|---|---|
| P1 | Registration, e-mail verification and trial | Owner, system | FR-IAM-01, FR-LIC-01 |
| P2 | Subscription lifecycle: expiry, grace period and suspension | System, owner | FR-LIC-04 |
| P3 | Changing plan or renewing by bank transfer | Owner, platform admin | FR-LIC-02, FR-LIC-07, FR-LIC-08 |
| P4 | Account credit and refunds | Owner, platform admin | FR-LIC-09 |
| P5 | Platform payment details | Platform admin | FR-LIC-10 |
| P6 | Staff invitation | Owner or manager, invitee | FR-TEN-04 |
| P7 | Creating a garage and the plan limits | Owner | FR-TEN-02, FR-LIC-02 |
| P8 | Linking a customer to a user | Service advisor, user (end customer) | FR-CUS-06, FR-CAPP-05 |
| P9 | Settling a repair order and collecting payments | Accountant, system | FR-BIL-01, FR-BIL-03, FR-BIL-05, FR-BIL-06 |
| P10 | Setting up the part catalogue: units, packaging, categories, price, images | Inventory staff, owner | FR-CAT-01 to FR-CAT-04, FR-CAT-07, FR-CAT-09 |
| P11 | Receiving stock (stock-in) | Inventory staff, supplier, system | FR-INV-01, FR-INV-03, FR-CAT-02; Phase 7: FR-INV-06, FR-INV-07, FR-INV-19 |
| P12 | Issuing parts to a repair order | System, inventory staff, service advisor | FR-INV-02, FR-INV-03, FR-CAT-02; Phase 7: FR-INV-15, FR-INV-20 |
| P13 | Counting stock and correcting it (Release 7.1) | Inventory staff, owner | FR-INV-09 to FR-INV-12 |
| P15 | Quoting with jobs, and changing, waiting and cancelling an order (Release 7.3) | Service advisor, owner, inventory staff | FR-CAT-05, FR-CAT-06, FR-CAT-08, FR-INV-15, FR-REP-07, FR-REP-08 |
2. Terms used
| Term | Meaning |
|---|---|
| Owner | The user who owns the subscription; one subscription covers all the owner’s garages |
| Platform admin | A user listed in PLATFORM_ADMIN_EMAILS; works in vietgara-admin-web or vietgara-admin-app |
| Period | The time a plan is paid for, one billing cycle; it has a start and an end |
| Request | A plan payment request: the owner’s quote, transfer declaration and the admin’s decision, in one record |
| Reference | The unique code of a request (for example VG-7K2M9Q) that the owner writes in the transfer note |
| Credit | The owner’s account credit, in VND, applied automatically to the next payment |
| Grace period | The 7 days after a period ends in which the garages are read-only |
| Part | An item with a SKU, in the Catalogue module (parts); Inventory keeps its stock |
| Base unit | The unit a part’s stock is counted and stored in (for example the can) |
| Packaging unit | Another unit of the same part with a fixed factor to the base unit (a case of 24 cans) |
| Entered quantity | What the user typed, in the unit chosen; the system stores it next to the base-unit quantity |
| On hand / reserved / available | Stock in the base unit; the part of it promised to draft stock issues (Phase 7); on hand minus reserved |
3. P1 — Registration, e-mail verification and trial
Actors: owner, system. Trigger: a person signs up in the web console or an app.
flowchart TD
A([Owner signs up]) --> B{How?}
B -- E-mail and password --> C[E-mail an OTP, valid 2 minutes]
C --> D{OTP correct within 5 attempts?}
D -- no, resend after 30 s --> C
D -- yes --> E[E-mail verified]
B -- Google --> E
E --> F[user.email_verified]
F --> G[Default roles are created]
F --> H[14-day trial subscription starts]
F --> I[Registration e-mail is sent]
H --> J([Owner creates the first garage])
Rules
- Until the e-mail is verified the user can only edit their profile and verify; garages, staff and business pages are blocked.
- A Google account is verified on sign-up, so the same event follows at once.
- The trial gives every feature and the limits of the trial plan for 14 days. It is given once, by the system; the trial plan can never be chosen again.
- A user without a subscription (an owner whose verification event was lost) gets the trial the first time the subscription is read; the creation is idempotent.
4. P2 — Subscription lifecycle: expiry, grace period and suspension
Actors: system (the expiry job), owner. Trigger: the clock; the job runs every LICENSE_EXPIRY_JOB_INTERVAL (default 1 hour).
stateDiagram-v2 [*] --> trial: e-mail verified trial --> active: payment approved trial --> past_due: trial ends active --> past_due: period ends without a payment past_due --> active: payment approved past_due --> suspended: 7 days of grace end suspended --> active: payment approved
sequenceDiagram actor O as Owner participant J as Expiry job participant N as Notification J->>N: 7 days before the end: subscription.expiring_soon N->>O: e-mail with the amount to renew and the bank details O->>O: renews (P3) Note over J: if the period ends without a payment J->>J: status past_due, grace period starts (garages are read-only) J->>N: subscription.status_changed N->>O: e-mail: read-only until the end of the grace period, how to pay Note over J: 7 days later J->>J: status suspended (garages are locked) J->>N: subscription.status_changed N->>O: e-mail: suspended, how to pay
| Status | What the owner can do |
|---|---|
trial, active | Everything the plan includes, within its limits |
past_due (grace) | Read everything, create nothing: the garages are read-only. Paying is allowed |
suspended | Sign in, see the subscription and pay; every request to a garage is refused until a payment is approved |
Rules
- The reminder is sent once per period, 7 days before the end, with the amount to renew and the platform’s payment details.
- The job never changes a plan; only an approved payment (P3) or a manual extension by a platform admin does, and either reactivates a
past_dueorsuspendedsubscription. - Each status change is published as
subscription.status_changedand the entitlement cache is refreshed; the console shows the status and what to do. - The owner is e-mailed when the subscription becomes
past_dueand when it becomessuspended, each time with the amount to pay and the platform’s bank details; a lapsed trial is worded as such. Reactivation is not e-mailed by this event: an approved payment has its own e-mail (P3).
5. P3 — Changing plan or renewing by bank transfer
Actors: owner, platform admin, system. Trigger: the owner chooses another plan, or renews the current one, in the console or the owner app. Until online payment arrives (Phase 16) this is the only way to pay.
sequenceDiagram
actor O as Owner
participant L as License
participant N as Notification
actor A as Platform admin
O->>L: choose a plan (change) or renew
L->>L: check the plan and the usage limits
L-->>O: quote: amount due, bank details, QR, reference
O->>O: transfer the amount with the reference in the note
O->>L: submit: payer, amount, bank reference, date, screenshot
L->>L: limits again, flag a mismatch with the quote
L-)N: payment_submitted
N->>A: e-mail: a request waits
A->>L: open the review queue and the evidence
alt the money is received
A->>L: approve (a note is required for a mismatch)
L->>L: limits again, plan applies, new period, credit, entitlements
L-)N: payment_approved
N->>O: e-mail: the plan is active
else it is not
A->>L: reject with a reason
L-)N: payment_rejected
N->>O: e-mail with the reason
end
stateDiagram-v2 [*] --> awaiting_payment: owner starts a request awaiting_payment --> awaiting_payment: replaced by a new request awaiting_payment --> submitted: owner submits the transfer awaiting_payment --> cancelled: owner cancels submitted --> cancelled: owner cancels before review submitted --> approved: admin approves submitted --> rejected: admin rejects
What the owner sees and does
- Opens Subscription, chooses a plan (or Renew). The quote shows the new price, the credit applied and the amount due, with the platform’s bank details, QR code and the reference.
- Transfers exactly the amount due and writes the reference in the transfer note.
- Returns to the request, enters the payer’s name, bank and account, the amount, the bank’s transaction code and the date, and attaches a screenshot of the transfer. When the credit covers the whole price the amount due is 0 and no transfer or screenshot is needed.
- Waits. The request shows its status; the owner is e-mailed when it is approved or rejected. A rejected owner starts a new request.
What the platform admin does
- Opens the review queue (oldest first) or follows the reminder e-mail.
- Compares the request with the bank statement: reference, amount, payer and date, using the screenshot.
- Approves, or rejects with a reason. When the amount transferred differs from the quote, a warning shows; the admin contacts the owner, and may approve with a note that records the outcome.
Rules
| Rule | Detail |
|---|---|
| One open request | An owner has one request in awaiting_payment or submitted. Starting another replaces one that still waits for payment; a submitted one must be cancelled or reviewed first (REQUEST_ALREADY_OPEN) |
| Plans | Any public, active plan other than the trial plan and the current plan; a renewal pays for the current plan again. A plan that is no longer offered but still active can be renewed by the owners on it |
| Limits | Garages and staff in use (members plus pending invitations) must fit the new plan, checked at submission and at approval; otherwise PLAN_LIMIT_REACHED lists what to reduce |
| Immediate effect | Approval applies the plan at once and starts a new period of its billing cycle. It does not wait for the end of the current period |
| Renewal | A renewal of the current plan before its end extends from the old end date, so no paid day is lost; after the end it restarts from approval and reactivates the subscription |
| Trial | The unused trial days are lost when the first payment is approved |
| Never expires | A submitted request has no deadline: the money has moved, so it waits for the admin however long it takes |
| Quote | Fixed when the request starts and honoured at approval, even if the plan’s price changes meanwhile; the bank details shown are kept with it too |
| Evidence | PNG, JPEG, WebP or PDF, up to 5 MB, private; shown to admins by short-lived signed link |
| Reminder | While requests are submitted, the admins are e-mailed once a day |
| Audit | Submission, approval, rejection and cancellation record who and when, with the values before and after |
Amount due
creditFromPeriod = floor(unusedDays / periodDays × price of the current period) // 0 for a trial and for a renewalavailable = creditFromPeriod + account creditcreditApplied = min(available, new price)amountDue = new price − creditAppliedsurplus = available − creditApplied // becomes account credit| Case | Current period | Account credit | New plan | Result |
|---|---|---|---|---|
| Upgrade after 10 of 30 days | 600,000 | 0 | 1,490,000 | credit 400,000; amount due 1,090,000 |
| Downgrade after 15 of 30 days | 600,000 | 50,000 | 100,000 | available 350,000; amount due 0; surplus 250,000 becomes credit |
| First payment from the trial | 0 (trial) | 0 | 599,000 | amount due 599,000; trial days lost |
| Renewal 5 days before the end | 600,000 | 100,000 | 600,000 (same) | no unused credit; amount due 500,000; the period extends from its old end |
Exceptions
| Situation | What happens |
|---|---|
| The owner transfers a different amount | The request is flagged; the admin resolves it with the owner and approves with a note, or rejects; the owner may then submit again |
| The owner transfers but never submits | The request stays awaiting_payment; the owner can submit later while it is open. Starting a new one replaces it and the reference changes, so the owner must use the latest |
| The usage grew after the quote | Approval is refused with PLAN_LIMIT_REACHED; the admin asks the owner to reduce usage or to choose another plan (reject, then a new request) |
| The admin rejects by mistake | The owner submits a new request; the earlier one stays in the history |
| Nobody reviews | The daily reminder continues; the subscription’s own expiry (P2) is not paused, but an approval reactivates it at any time |
Requirements: FR-LIC-02, FR-LIC-07, FR-LIC-08, FR-LIC-09, FR-NOT-04. API: API Specification Section 4 (/me/subscription/payment-requests, /admin/plan-payment-requests). Design: Low-Level Design Section 3.3; Database Design Section 6.8. Scenarios: E2E-LIC (License & Subscription).
6. P4 — Account credit and refunds
Actors: owner, platform admin.
flowchart LR
A[Plan change before the period ends] --> B[Unused value of the period]
B --> C{More than the new price?}
C -- yes --> D[Surplus becomes account credit]
C -- no --> E[Reduces the amount due]
D --> F[Applied automatically to the next payment]
F --> G[Ledger entry: applied]
H[Admin correction] --> I[Ledger entry: adjustment, note required]
Rules
- Credit is in VND, never negative and visible to the owner with its history (earned, applied, adjustment).
- It is applied to every next payment before any transfer is asked for.
- Refunds are paid manually by the platform outside the system; the admin then records an adjustment with a note that points at the transfer. There is no automatic refund.
- A manual correction is an audited, signed amount with a mandatory note; a correction that would take the balance below 0 stops at 0.
7. P5 — Platform payment details
Actors: platform admin. Trigger: the bank account changes, or a first setup.
- The admin opens Payment details and edits the bank, account number, account holder, branch and transfer instructions, and uploads the QR code image (PNG, JPEG or WebP, up to 1 MB).
- The change is audited with who and when, and the values before and after.
- New requests and the 7-day renewal e-mails show the new details at once. A request keeps the details that were shown to its owner when it started, so a review always compares with what the owner actually saw.
8. P6 — Staff invitation
Actors: owner or manager (staff.write), invitee, system. Trigger: the owner wants a person to work in a garage. Only existing users can be invited; a person without a user registers first (P1).
sequenceDiagram
actor O as Owner or manager
participant T as Tenant
participant L as License
participant M as Mail
actor S as Invitee
O->>T: look up by e-mail or phone
T-->>O: candidate: can be invited, already a member, or already invited
O->>T: invite with the staff groups
T->>L: staff limit: members plus pending invitations
T->>M: invitation e-mail in the invitee's language
alt accepts
S->>T: accept
T->>T: membership and groups are created
else declines
S->>T: decline
else the owner withdraws
O->>T: revoke
else 7 days pass
T->>T: expired
end
stateDiagram-v2 [*] --> pending: invited pending --> accepted: invitee accepts pending --> declined: invitee declines pending --> revoked: owner revokes pending --> expired: 7 days
Rules
- One pending invitation per garage and user; inviting a current member or an already invited user is refused (
ALREADY_MEMBER,ALREADY_INVITED). - A pending invitation counts against the plan’s staff limit as if the person had joined, so the limit cannot be exceeded by accepting later (
PLAN_LIMIT_REACHED). - Only the invitee can accept or decline; another user opening the link is refused. An expired or reviewed invitation cannot be accepted (
INVITATION_EXPIRED,INVITATION_NOT_PENDING). - Accepting joins the garage with the invited groups; permissions come from the groups’ roles. Staff can then be set Inactive or removed (the person keeps their user).
- The invitee’s e-mail is in their own language (FR-IAM-07).
9. P7 — Creating a garage and the plan limits
Actors: owner. Trigger: the owner adds a garage (branch).
flowchart TD
A([Owner creates a garage]) --> B{E-mail verified?}
B -- no --> X1[Refused: verify first]
B -- yes --> C{Subscription allows writing?}
C -- grace or suspended --> X2[Refused: read-only or locked, pay first - P3]
C -- yes --> D{Active garages below the plan limit?}
D -- no --> X3[PLAN_LIMIT_REACHED: choose a larger plan - P3]
D -- yes --> E[Garage and default staff groups created]
E --> F[garage.created]
Rules
- The limit counts active garages across all the owner’s garages; setting a garage Inactive frees a place.
- Every garage under one user shares the one subscription (a branch model); staff and daily transactions are also counted per owner.
- Exceeding the limit is solved by changing plan (P3); paying per extra garage is planned for Phase 16 (FR-LIC-06).
10. P8 — Linking a customer to a user
Actors: service advisor (customer.write), the person as a user in the Customer App, system. Trigger: a garage wants its customer profile tied to the person’s VietGara user, so the person sees their vehicles and quotes. A customer needs no user: a walk-in profile is complete without a link.
sequenceDiagram
actor SA as Service advisor
participant C as Customer and Vehicle
participant I as Identity
participant M as Mail
actor U as User (Customer App)
SA->>C: send a link request for a customer with an e-mail
C->>I: does a verified user own this e-mail?
I-->>C: yes, no, or no answer revealed
alt no verified owner
C-->>SA: refused: the garage never learns who the user is
else a user owns it
C->>M: one-time code to the user, valid 2 minutes
C-->>SA: request sent (status requested)
alt the user reads the code to the advisor
SA->>C: enter the code
C->>C: linked through OTP
else the user answers in the app
U->>C: accept
C->>C: linked through APP
else the user declines
U->>C: decline
C->>C: declined
end
end
stateDiagram-v2 [*] --> requested: the garage sends a request requested --> linked: code entered, or the user accepts requested --> declined: the user declines requested --> unlinked: the garage cancels, or the e-mail changed hands linked --> unlinked: the garage or the user ends the link [*] --> linked: sign-in match by verified e-mail (auto)
Rules
- Only a verified e-mail can be matched. The garage learns only that a request was sent, never who the user is; a request for an e-mail nobody owns is refused.
- A customer has at most one current link (
requestedorlinked). Declined and ended links stay as history. - Automatic link at sign-in: a user’s verified e-mail links matching profiles when the user signs in, but only when a profile has never had a link with that user (a refusal or an unlink is respected), no other user is linked to it, and no request is still waiting (that one is answered in the app).
- Ending a link, from either side, keeps the customer profile and its vehicles and documents.
- If the person has no user or declines, staff keep or create a walk-in customer without one.
- Phone matching waits for verified phones (Release 2.4); until then a request matches by e-mail only.
- Link changes are audited (
customer.link_requested,customer.link_confirmed,customer.link_answered,customer.link_ended) without naming the user.
Exceptions
| Situation | What happens |
|---|---|
| The customer has no e-mail, or no verified user owns it | The request is refused with FAILED_PRECONDITION; the advisor keeps the walk-in profile |
| The code expires or is wrong | OTP_EXPIRED / OTP_INVALID; the advisor sends the request again (the code is re-sent, at most once every 30 seconds) |
| The customer is already linked | 409 ALREADY_EXISTS |
| A request waits for one user and the e-mail now belongs to another | The old request ends and a new one starts for the new owner |
Requirements: FR-CUS-06, FR-CAPP-05. API: API Specification Sections 5 and 14.2. Design: Database Design Section 3.4; Event Design (audit actions).
11. P9 — Settling a repair order and collecting payments
Actors: accountant (billing.write), system. Trigger: a repair order is closed (every line completed).
flowchart TD
A([Repair order closed]) --> B[Create the settlement]
B --> C{Billing party}
C -- default or chosen --> D[Settlement pending: total, billing party, garage legal identity as a snapshot]
D --> E{VAT invoice requested?}
E -- party incomplete --> X[INVOICE_DATA_INCOMPLETE: complete the tax profile or bill as retail]
E -- yes, complete or no --> F[Record a payment]
F --> G[Payment voucher issued and numbered]
G --> H{Paid in full?}
H -- no --> I[Status partially_paid]
I --> F
H -- yes --> J[Status paid]
G --> K[Voucher PDF printed]
G --> V{Recorded by mistake?}
V -- yes --> W[Void with a reason: totals recalculated, voucher stays marked void]
W --> F
Rules
- A settlement is created from a closed repair order, once (
ALREADY_EXISTSif it exists), and counts against the plan’s daily transaction limit. It copies the garage’s legal identity; later edits never rewrite it. - Creating a settlement and recording a payment both need an
Idempotency-Key; a retry is answered from the first attempt and never recorded twice. A key reused for another request is refused (IDEMPOTENCY_KEY_REUSED). - The billing party defaults to the repair order’s (the quote’s), else the customer’s default tax profile, else retail. It can be changed on the settlement together with invoice requested; a VAT invoice needs a legal name, tax ID and address.
- A payment is the amount actually collected, positive and at most what is still due, with its method. Each payment gets its own voucher (phiếu thu) numbered from the garage’s template, with the payer, reason, amount in figures and words, collector and signature blocks.
- Several payments may collect the total. The status is derived:
pending,partially_paid,paid; a zero-total settlement ispaidat once. - A wrong payment is voided with a reason (up to 500 characters); the voucher stays on file marked void, the totals and status are recalculated and the audit log records who did it. The correct payment is then recorded again.
- Online payment and e-invoice issuing arrive in Phase 16; until then the garage collects at the counter or by transfer and records the payment.
Exceptions
| Situation | What happens |
|---|---|
| The repair order is not closed | FAILED_PRECONDITION; close it first |
| The connection drops after pressing record | Retrying with the same key returns the first answer; nothing is recorded twice |
| The amount exceeds what is due | VALIDATION_ERROR |
| A voucher is voided twice | 409 ALREADY_EXISTS |
| The subscription is read-only | Recording is refused until it is renewed (P2, P3) |
Requirements: FR-BIL-01, FR-BIL-03, FR-BIL-05, FR-BIL-06, FR-CUS-07, FR-DOC-01. API: API Specification Sections 1.3, 9 and 10. Design: High-Level Design Section 4.1; Low-Level Design Section 4.1. Scenarios: E2E-BIL.
12. P10 — Setting up the part catalogue
Actors: inventory staff or owner (catalogue.write). Trigger: a new part, a new way of packaging it, or a price change. The part, its price, units, packaging, categories and images all belong to the Catalogue module; only the stock of the part belongs to Inventory (HLD Section 3.1). The steps below are the same for the user.
flowchart TD
A([Need to stock a new part]) --> B{Unit exists?}
B -- no --> C[Create the unit once per garage: can, case, litre]
B -- yes --> D
C --> D[Create the part: SKU, name, base unit, selling price, category, brand, reorder threshold]
D --> E{Sold or bought in packs?}
E -- yes --> F[Add packaging units with a factor: 1 case = 24 cans, optional own price]
E -- no --> G
F --> G[Attach up to three images]
G --> H([Part ready to receive, quote and issue])
H --> I{Later change?}
I -- price --> J[New price applies to new quotes only]
I -- not used any more --> K[Delete or deactivate]
Rules
- Units are per garage. A unit has a name and an abbreviation; names are unique per garage, ignoring letter case. The factor is not on the unit but on the part: a case of beer is 24 cans, a case of filters is 10 pieces.
- Base unit. Every part has exactly one, chosen when the part is created. All stock, thresholds, and quantities on quotes and repair orders are kept in it. It cannot be changed once the part has stock movements, packaging units or document lines; create a new part instead, because every stored number would silently change meaning.
- Packaging units. A part may have any number of packaging units, each with a factor above zero (decimals allowed) and optionally its own selling price. The base unit is never one of them, a unit appears once, and an inactive unit cannot be used. The table is saved as a whole.
- Selling price is what the customer pays per base unit, in whole dong. A packaging unit costs base price × factor unless it has its own price: 9,000 per can makes a case 216,000, or 200,000 when overridden. A quote line prefilled from the part keeps its own copy of the price, so changing the price never changes an existing quote. Everyone with
catalogue.readsees the price (all default roles hold it); cost is separate and needsinventory.cost.read(Phase 7), which also adds a warning when a price is below the average cost. - Categories form a tree of at most three levels (Engine oil > Synthetic > 5W-30). Names are unique among siblings. A part has at most one category and one brand (free text); lists and pickers filter by SKU, name, category (including its sub-categories) and brand.
- Images. One to three per part (PNG, JPEG or WebP), uploaded first and then attached; the first is the thumbnail. Images are public files (like the logo), resized on upload.
- Delete or deactivate (FR-CAT-07). The dialog says which one will happen. A unit, category or part that a document, stock movement or part refers to is deactivated: it disappears from pickers, stays on what already uses it and can be reactivated. One used nowhere is deleted. A category with sub-categories is never deleted from under them.
Exceptions
| Situation | What happens |
|---|---|
| The same SKU twice in a garage | 409 ALREADY_EXISTS |
| No base unit given | VALIDATION_ERROR |
| A packaging unit equal to the base unit, repeated, inactive or with factor 0 | VALIDATION_ERROR; nothing is saved |
| A fourth level of category, or a fourth image | VALIDATION_ERROR |
| Changing the base unit of a part with stock | Refused (FAILED_PRECONDITION) |
| Deleting a unit that parts use | The unit is deactivated instead; the answer says so |
| The subscription is read-only | The catalogue is read-only too (P2) |
Requirements: FR-CAT-01 to FR-CAT-04, FR-CAT-07, FR-CAT-09. API: API Specification Sections 1.5 and 8. Design: Database Design Section 5. Scenarios: E2E-INV, E2E-CAT (to be written with Release 6.0).
13. P11 — Receiving stock (stock-in)
Actors: inventory staff (inventory.write), the supplier, the system. Trigger: a delivery of parts arrives.
Today (up to Release 6.x): one part per receipt
flowchart TD
A([Delivery arrives]) --> B[Check the goods against the delivery note]
B --> C[Open the part and choose Receive stock]
C --> D{Counted in packs?}
D -- yes --> E[Pick the packaging unit, type the number of packs]
D -- no --> F[Type the quantity in the base unit]
E --> G[Optional: unit cost]
F --> G
G --> H[Save]
H --> I[System converts to the base unit, records an IN movement, raises on hand]
I --> J([Part shows the new quantity; the low-stock badge clears if it was low])
Rules
- A receipt is one part: there is no multi-line document yet. A delivery of ten parts is ten receipts.
- The quantity is typed in the unit the goods were counted in. A packaging unit is converted by the backend (10 cases of 24 = 240 cans); the movement stores the 240 and, next to it, the unit and the 10 that were typed, so the movements report and the Excel export show both. A conversion that would need more than three decimals is refused, never rounded.
- The unit cost is the price per entered unit in VND, optional (0 means “not recorded”). The system stores it per base unit (case price ÷ factor), so a later average cost compares like with like.
- The movement is append-only: it is never edited. The balance changes in the same database transaction as the movement, so the two cannot disagree.
- There is no supplier, number or print on a receipt yet; those come with the document in Phase 7.
Exceptions
| Situation | What happens |
|---|---|
| Quantity zero, negative or not a number | VALIDATION_ERROR |
| The unit is not a packaging unit of that part, or is the base unit (leave the unit empty instead) | VALIDATION_ERROR |
| A mistake after saving | A receipt cannot be edited or deleted. Check the quantity before saving; corrections (counts, write-offs, returns) are made with Release 7.1 documents (P13) |
| The subscription is read-only | Refused until it is renewed (P2, P3) |
From Release 7.0: the goods receipt document
flowchart TD
A([Delivery arrives]) --> B[New goods receipt: date, delivery note number, optional default supplier]
B --> C[Add lines by part search]
C --> D[Each line: part, unit and quantity, unit cost, supplier]
D --> E{Every line has a supplier?}
E -- no --> X[Completing is blocked: pick the supplier on the line]
X --> D
E -- yes --> F[Save as draft any time]
F --> G[Complete]
G --> H[Movements per line, stock and weighted-average cost updated, part's last cost per supplier updated]
H --> I{Receipt entered wrongly?}
I -- yes --> J[Cancel: movements reversed, cost recalculated, audited]
- The receipt has a number from the garage’s template and the status
draft→completed→cancelled. A draft changes nothing in stock. A completed receipt cannot be edited, only cancelled. - Every line records its supplier (a supplier of the garage). The header supplier is only a default; a part’s default supplier pre-fills a new line; one delivery from several suppliers is one document.
- Completing a line adds its base quantity and updates the part’s weighted-average cost: new average = (quantity on hand × old average + received quantity × received cost) ÷ (quantity on hand + received quantity). Each movement keeps its own cost, so past figures never change. Cost is shown only with
inventory.cost.read. - Cancelling a completed receipt posts reversing movements and restores the previous quantity and average; it is audited. It is refused when part of its stock has already been issued, because the stock would go below zero.
- Lines can also be added by scanning a barcode or by Excel import (P14, Release 7.2). Purchase orders and supplier payables come later (Phase 13); supplier returns with Release 7.1.
Requirements: FR-INV-01, FR-INV-03, FR-INV-05, FR-CAT-02; Phase 7: FR-INV-06, FR-INV-07, FR-INV-08, FR-INV-19. API: API Specification Section 8 (goods receipts, Section 8.3; stock issues, Section 8.5). Design: Database Design Sections 5.3, 5.4, 5.10 and 5.12. Scenarios: E2E-INV-011 to E2E-INV-015.
14. P12 — Issuing parts to a repair order
Actors: the system (creates and posts the draft), inventory staff, service advisor. Trigger: a repair order with part lines is created, and parts leave the shelf.
Up to Release 6.x: issued when the order is closed
flowchart TD
A([Repair order with part lines]) --> B[Lines are completed, then Close]
B --> C[repair_order.closed event]
C --> D{Part line linked to a catalogue part?}
D -- no, free text --> E[Nothing is deducted]
D -- yes --> F[OUT movement per part in the base unit, balance decreases]
F --> G[stock.issued event]
Rules
- Parts leave stock when the repair order is closed, not when it is created or started. Only part lines that point to a stocked part deduct anything; a free-text part line and every labor line deduct nothing.
- The line quantity is already in the base unit (a quote or repair-order line typed in cases is converted when it is saved; the entered unit stays for printing), so 30 typed cans deduct 30, and one case deducts 24.
- The issue is idempotent: a redelivered close event cannot deduct twice (a unique movement per repair order, part and order id).
- An issue never fails for lack of stock. On hand can go below zero, which shows as low stock; the garage corrects it with a receipt. Warning the advisor in time is what the Phase 7 reservation is for.
Exceptions
| Situation | What happens |
|---|---|
| Closing with a line not completed | FAILED_PRECONDITION; complete the lines first |
| The close event arrives twice | The second is ignored |
From Release 7.0: the draft stock issue (phiếu xuất kho)
flowchart TD
A([Repair order created]) --> B[A numbered draft stock issue is created with one line per part line]
B --> C[Draft reserves the parts: on hand unchanged, available drops]
C --> D{When is it posted?}
D -- order closed, default --> P
D -- Issue now, by inventory staff --> P
D -- order started, garage setting --> P
P[Post: OUT movements with a cost snapshot per line, balance decreases, reservation consumed] --> Q([Corrections are returns or adjustments, P13])
- The draft is the reservation: a part shows on hand, reserved (in draft issues) and available (the difference).
- Posting writes the movements and a cost snapshot per line; it is idempotent. After posting the issue is read-only.
- The garage chooses when it posts (
stock_issue_mode: on close by default, or when work starts, which is the first move of a line out of open); inventory staff can always post earlier with Issue now when the parts physically leave the shelf. - A stock issue never blocks a repair order: when the reservation is more than the stock on hand, available drops below zero and the order is not held back. From Release 7.3 such an order is shown as waiting on parts (P15).
- Closed in Release 7.3. Lines changed on an order, and a cancelled order, now reach the draft (P15).
Requirements: FR-INV-02, FR-INV-03, FR-CAT-02; Phase 7: FR-INV-15, FR-INV-20. API: API Specification Sections 7 and 8; Event Design (repair_order.created, repair_order.started, repair_order.closed, stock.issued). Design: Database Design Sections 5.4, 5.11 and 11.6; High-Level Design Section 3.1. Scenarios: E2E-INV-008, E2E-INV-017 to E2E-INV-019.
15. P13 — Counting stock and correcting it (Release 7.1)
Actors: inventory staff (inventory.count, inventory.adjust), owner. Trigger: a periodic count, or a loss, damage, supplier return or unused part found. Until Release 7.1 stock changed only through receipts (P11) and repair-order issues (P12); the adjustments below are in place from Release 7.1.
flowchart TD
A([Need to correct stock]) --> B{What happened?}
B -- periodic or spot check --> C[Stock count document]
B -- lost, damaged, expired, internal use, warranty --> D[Write-off with a reason]
B -- goods sent back --> E[Return to supplier]
B -- part not used on an order --> F[Return from the repair order]
C --> C1[Add lines by search, category or barcode; type the counted quantity]
C1 --> C2[Each line shows matched, over or short; drafts can be saved and merged]
C2 --> C3[Complete: adjustments make stock equal the count, value of the difference recorded]
D --> G[Reduces stock at average cost, numbered document]
E --> H[Reduces stock, records the supplier credit, linked to the receipt if known]
F --> I[Restores stock and reverses the cost, reason recorded]
Rules
- A count compares the counted quantity with the system quantity at the time of counting: the system quantity is recorded when a part is added and again each time its counted quantity is typed, and completing the count takes a final snapshot. Each line shows matched, over, short or uncounted. A count can be saved as a draft and several drafts can be merged into the first one: a part on more than one draft keeps one line with the counted quantities added up, and the other drafts are cancelled with a note.
- Completing a count is refused while a line is uncounted (
STOCK_COUNT_HAS_UNCOUNTED_LINES), unless the user chooses to skip the uncounted lines, which then stay as they are. Completing posts one adjustment per differing line, so on hand equals the count, and values the difference at the part’s current average cost. Adjustments never move the average cost. A completed count cannot be edited. - Cancelling a draft only closes it. Cancelling a completed count posts the reversal; it is refused (
INSUFFICIENT_STOCK_TO_REVERSE) when the stock the count added has since been used. - A write-off is posted when it is created, with its reason, at the average cost of that moment. It is refused (
INSUFFICIENT_STOCK) for any line that is more than the quantity on hand. Cancelling it puts the parts back at the cost they left with. - A return to supplier is posted when it is created. The credit for a line is the cost typed, else the cost of the same part on the linked completed receipt of that supplier, else the average cost. The credit note number is typed when the return is made and does not change afterwards. Cancelling it puts the parts back.
- A return of unused parts from a repair order is possible only once its stock issue is posted (
STOCK_ISSUE_NOT_POSTED). It restores the stock at the cost the line left with, and the total returned from a line cannot exceed what was issued on it (RETURN_EXCEEDS_ISSUED). The reason is required (up to 500 characters). The return is immutable and does not change the repair order’s lines or what the customer is billed. - Counting and adjusting are separate permissions, so a counter does not need to be able to write off stock. The manager and inventory staff hold both by default; a technician or an accountant does not.
- Every adjustment is an immutable movement with the user, the document and the reason; the part’s stock card shows them in order, together with receipts and issues.
- Quantities in a count or adjustment may be entered in any of the part’s units and are converted like any other (P11). A count line may be counted at zero, which is a real count of none.
- A count can also be filled by scanning (the part is added when missing and the cursor goes to its counted quantity) or by Excel import (P14).
Requirements: FR-INV-09, FR-INV-10, FR-INV-11, FR-INV-12. Plan: Release Plan Phase 7, Release 7.1. Design: Database Design Section 5.13. Scenarios: E2E-INV-020 to 029 in 07_Inventory.md.
16. P14 — Scanning barcodes and importing from Excel (Release 7.2)
Actors: inventory staff, owner (and the manager). Trigger: a garage starts using VietGara and has its parts in a spreadsheet; a delivery, or a shelf, is to be entered quickly with a scanner.
flowchart TD
A([Need to enter many parts or lines]) --> B{How?}
B -- one by one, with a scanner --> S[Scan: a USB scanner types into the field, the phone camera reads the label]
S --> S1{Code known?}
S1 -- no --> S2[Told on the spot; type another or add the barcode to the part]
S1 -- inactive part --> S3[Told: the part is inactive]
S1 -- yes --> S4[Receipt: a line, or one more; count: the part and its counted quantity]
B -- from a file --> D[Download the template for parts, a goods receipt or a stock count]
D --> E[Fill it in and choose the file]
E --> F[Preview: every problem listed with row, column and reason, nothing saved]
F --> G{Any problem?}
G -- yes --> H[Fix the file and choose it again]
H --> F
G -- no --> I[Confirm: all rows are imported in one step]
I --> J[Parts: created, opening stock posted as one completed receipt]
I --> K[Receipt or count file: one draft document to review and complete]
Rules
- A part can have up to 20 barcodes. A code is unique in the garage (compared without regard to case) and is for the base unit or for one of the part’s packaging units, so scanning a case code on a receipt adds cases. Deleting a part frees its codes.
- Scanning needs no special permission beyond reading parts. An unknown code is told on the spot; a code of an inactive part says the part is inactive. A scan on a receipt adds a line, or one more of the same part and unit; on a count it adds the part when missing and moves the cursor to its counted quantity. The phone app scans into counts, write-offs, returns and the stock list; it does not edit barcodes.
- An import has three steps and the preview writes nothing. The preview reports every problem of the file at once, so the user fixes the file once and chooses it again. A file with any problem is refused as a whole on confirm (
IMPORT_FILE_ALREADY_IMPORTEDandIMPORT_FILE_INVALIDare the two refusals); nothing is partly imported. - The parts file creates parts only. A SKU or barcode already in the garage, or repeated in the file, is an error. Missing units and part categories are created. A row with an opening quantity names a supplier; all such rows become one completed goods receipt (each line with its own supplier), so stock and the average cost follow the usual rules (P11). The unit cost needs
inventory.cost.read. - The goods receipt and stock count files each make one draft; the user reviews it and completes it like any other document. Lines are found by SKU or barcode; the columns that belong to the whole document must agree wherever they are filled.
- Permissions follow the file: catalogue write for parts, plus inventory write for opening stock and cost access for costs; inventory write for receipts; inventory count for counts. A role that may not use a column is told so before anything is saved.
- A confirmed file cannot be confirmed again unchanged. Every confirmed import is audited and kept in the import history.
- Limits: 5 MB and 5,000 rows per file.
Requirements: FR-INV-13, FR-INV-14, FR-INV-06, FR-INV-12. Plan: Release Plan Phase 7, Release 7.2. API: API Specification Sections 8.1 and 8.10. Design: Database Design Section 5.14. Scenarios: E2E-INV-030 to E2E-INV-038 in 07_Inventory.md.
17. P15 — Quoting with jobs, and changing, waiting and cancelling an order (Release 7.3)
Actors: service advisor (quote.write, repair_order.write), owner or manager (the job catalogue), inventory staff (receipts). Trigger: a customer asks for a common piece of work; or an order needs another part, or the customer drops it; or the stock for an order is not on the shelf.
flowchart TD
A([Customer asks for a job]) --> B[Pick the job on the quote]
B --> C[Labor line + a line per suggested part; hidden consumables marked]
C --> D[Edit lines as needed; send for approval]
D --> E[Customer decides on the billable lines]
E --> F[Convert: approved lines + the consumables of approved jobs become the order]
F --> G[Draft stock issue reserves the parts]
G --> H{Enough stock?}
H -- no --> I[Order waits on parts]
I --> J[Stock arrives, or another order is cancelled]
J --> K[Order is back to open]
H -- yes --> K
K --> L{Change of plan?}
L -- another part or quantity --> M[Add, edit or remove a line; the draft follows]
L -- customer drops it, no work started --> N[Cancel: reservation released]
L -- carry on --> O[Work, complete, close: the issue posts]
Rules
- A job is a type of work with a standard labor price, a duration and up to 30 suggested parts. Owners and advisors maintain the catalogue; a job that a quote or an order used is only deactivated when deleted, and an inactive job leaves the picker but stays on what used it. A part or packaging unit that a job suggests cannot be deleted or removed from under it.
- Picking a job on a draft quote adds a labor line and a line per active suggested part, at today’s prices and in the suggested unit. The advisor edits them like any line (change 4 litres to 5, change a price, remove a line).
- A suggested part can be a hidden consumable (grease, screws, cleaning fluid): it is stocked and deducted but not billed. The customer does not see it on the approval page or the PDFs and does not decide on it; it follows its job, so it reaches the order only when a line of the job was approved.
- Lines of an open order can be added, edited and removed until it is closed or cancelled; the draft stock issue follows part lines. After the issue is posted (the garage’s start-of-work mode, or “Issue now”) the parts are fixed; labor and prices can still change.
- Waiting on parts. If the stock on hand cannot cover the draft issue, the order is shown as waiting on parts and the issue shows how much is short. Orders are served first come, first served per part. Stock arriving, or another order’s reservation freed, brings the order back by itself. It never blocks the work.
- Cancelling an order is possible while it is open or waiting on parts and no line has started. Its reservation is released at once; the quote stays converted (the customer gets a new quote); the order stays on the vehicle’s history as cancelled and is read-only. Once work started, the order is completed or closed, or its stock issue is posted, it cannot be cancelled: parts that already left the shelf come back through a return (P13).
Exceptions
| Situation | What happens |
|---|---|
| Job inactive, or the quote is not a draft | Refused (JOB_INACTIVE, or the quote cannot be changed) |
| A suggested part was deactivated since | That suggestion is skipped |
| A quote would pass 100 lines | Refused; remove lines first |
| Part line changed after the issue is posted | Refused (STOCK_ISSUE_POSTED) |
| Removing the last billable line of an order | Refused; cancel the order instead |
Requirements: FR-CAT-05, FR-CAT-06, FR-CAT-08, FR-INV-15, FR-REP-07, FR-REP-08. Plan: Release Plan Phase 7, Release 7.3. API: API Specification Sections 7, 8 and 16.8. Design: Database Design Sections 4 and 5.15; Event Design (repair_order.lines_changed, repair_order.cancelled, stock_issue.shortage_changed). Scenarios: E2E-CAT-010 to E2E-CAT-013, E2E-QUO-015 to E2E-QUO-017, E2E-REP-013 to E2E-REP-018.
18. Keeping this document current
A procedure changes in the same pull request as the requirement or the design it belongs to. Diagrams are Mermaid, like the rest of the documentation. A new multi-party procedure (for example a supplier link in Phase 17, or the claim flow) gets its own section here, with the same parts: actors, trigger, diagram, rules, exceptions and references.