A two-document blueprint: what we're selling and how it works (for the commercial founder), and how it's built (for the technical founder) — for the single idea selected from the Malaysia opportunity scan: per-shipment job costing for SME freight forwarders and customs brokers.
A web app for freight forwarders and customs brokers that replaces the Excel sheet they currently use to work out whether a shipment actually made money. For every job, it captures what was billed and every cost against it — ocean/air freight, duty, handling, trucking, documentation fees — and shows the margin immediately, per shipment, per client, and per trade lane. It also holds the shipment's documents (Bill of Lading, Certificate of Origin, permits) in one place, with alerts before anything expires.
A forwarder's margin on any one job depends on a dozen cost lines that arrive at different times from different parties (the shipping line's invoice, the trucker's invoice, port handling charges, customs duty). Today these get pasted into a shared Excel workbook, usually by whoever has time, and nobody can say with confidence which clients or lanes are actually profitable until someone reconciles everything at month-end — by which point the next quote on that same lane has often already gone out underpriced.
SME freight forwarders and customs brokers in Malaysia with roughly 5–30 staff, running a steady volume of ocean/air shipments, who do not already run an enterprise TMS (Transport Management System) because it is priced and built for much larger operations. The buyer is the owner or operations manager — usually the same person who currently maintains the Excel workbook or directly supervises whoever does.
Assumption to validate: whether ops staff should see every job by default or only their own. Forwarders with a small, trusted team may want full visibility; larger ones may want it restricted. Confirm with the first pilot customer rather than deciding this up front.
Know which shipments, clients, and lanes are actually profitable the moment the last cost comes in — not at month-end reconciliation, when it's too late to reprice the next quote.
One pilot forwarder can replace their Excel workbook for new shipments: create a job, log costs against it from multiple staff, see the margin, and store the job's documents with expiry reminders. Nothing more.
| Priority | Feature | Why this priority |
|---|---|---|
| P0 | Shipment job creation (client, billed amount, lane) | The entire product is built around a job record |
| P0 | Cost line items against a job, multi-user | This is the Excel-replacement core |
| P0 | Live margin per job | The core value proposition |
| P0 | Document upload (BL/CO/permit) with expiry date and reminder | Second-most requested pain in forwarder workflows; cheap to add once file storage exists |
| P0 | Owner/Admin vs. Ops staff roles | Needed from day one — multiple staff log costs, only the owner should delete |
| P1 | Margin rollups by client and by trade lane | Valuable but not required to replace the Excel sheet for a single pilot |
| P1 | CSV import of historical shipments | Helps onboarding once the MSP is proven wanted, not before |
| P1 | Quote templates seeded from past job cost structures | A natural next step, not needed to prove the core loop |
| P2 | Multi-currency cost entries with live FX conversion | Only needed once a pilot customer actually books non-MYR cost lines regularly |
| P2 | Client-facing shipment status portal | A different product surface (client self-service), not core to the margin problem |
| P2 | Direct accounting-software sync (Xero/AutoCount) | High integration cost for a benefit the MSP doesn't need to prove |
For pitching: lead with the month-end problem — "you find out a shipment lost money three weeks after you could have done anything about it." The document-expiry reminder is a secondary hook, not the opener; margin visibility is what justifies the subscription price.
flowchart LR
A["Forwarder owner hears\nabout the product"] --> B{"Currently tracking\nmargin in Excel?"}
B -- "Yes, in pain" --> C["Books a demo"]
B -- "No real pain" --> X["Not a fit — exit"]
C --> D["Sees live-margin demo\non a sample shipment"]
D --> E{"Agrees to pilot?"}
E -- "No" --> X
E -- "Yes" --> F["Setup call:\ncompany + staff invited"]
F --> G["Team logs NEW shipments\ngoing forward"]
G --> H{"Margin visibility\nactually used weekly?"}
H -- "No" --> R["Churn risk —\nfounder follow-up"]
H -- "Yes" --> I["Converts to paid\nmonthly subscription"]
I --> J["Renews month over month"]
flowchart TD
A["Pilot agreement signed"] --> B["Owner creates\ncompany account"]
B --> C["Owner invites ops staff\nby email"]
C --> D{"Staff accepts invite?"}
D -- "No, after 3 days" --> E["Owner re-sends\nor removes invite"]
D -- "Yes" --> F["Staff sets password,\nlands on empty dashboard"]
F --> G["Owner or staff creates\nfirst real shipment job"]
G --> H["Team logs costs on it\nas invoices arrive"]
H --> I["First margin view\nshown — activation moment"]
flowchart TD
A["Create job:\nclient, billed amount, lane"] --> B["Job appears in\nopen-jobs list"]
B --> C["Any staff member logs\na cost line item"]
C --> D["Margin recalculates\nimmediately"]
D --> E{"More invoices\nstill expected?"}
E -- "Yes" --> C
E -- "No" --> F["Owner marks\njob closed"]
A --> G["Upload BL / CO / permit\nto the job"]
G --> H{"Document has\nan expiry date?"}
H -- "Yes" --> I["Reminder scheduled\n14 days before expiry"]
H -- "No" --> J["Stored, no reminder"]
I --> K{"Renewed before\nexpiry?"}
K -- "No" --> L["Overdue flag shown\non job"]
K -- "Yes" --> J
flowchart TD
Owner["Owner / Admin"] -->|"full access"| Settings["Company settings,\nbilling, staff accounts"]
Owner -->|"full access"| AllJobs["All shipment jobs\nand margin reports"]
Owner -->|"only role that can"| Delete["Delete a job or\na cost line item"]
Staff["Ops staff"] -->|"create / edit own"| OwnJobs["Jobs they created\nor are assigned to"]
Staff -.->|"optional, owner-granted"| AllJobs
Staff -->|"cannot"| Delete
flowchart TD
A["Pilot period ends\n(target: 4 weeks)"] --> B{"Owner decides\nto subscribe?"}
B -- "No" --> X["Account frozen,\ndata kept 30 days"]
B -- "Yes" --> C["Setup fee invoiced\n(RM2,000 one-time)"]
C --> D["Monthly plan selected\nby shipment volume"]
D --> E["Recurring card or\nFPX charge via payment gateway"]
E --> F{"Payment succeeds\neach cycle?"}
F -- "No" --> G["Grace period 7 days,\nowner notified"]
G --> H{"Resolved in\ngrace period?"}
H -- "No" --> X
H -- "Yes" --> F
F -- "Yes" --> I["Account stays active"]
flowchart TD
A["Owner opens\nCompany Settings"] --> B["Invite / remove staff"]
A --> C["View billing &\ninvoice history"]
A --> D["Set expiry reminder\nlead time (default 14 days)"]
A --> E["Export all shipment\ndata as CSV"]
B --> F{"Staff removed\nmid-pilot?"}
F -- "Yes" --> G["Their logged jobs\nreassigned to Owner,\naudit trail kept"]
| Stage | Technical founder | Commercial founder |
|---|---|---|
| Discovery | Confirm document upload/expiry and multi-user cost entry are technically straightforward (they are — see §B·1) | Interview 3–5 forwarders: do they actually re-key shipment data into a new system, or resist it? This is the key risk (see callout below) |
| Planning | Lock the MSP scope in §A·1; resist adding lane/client rollups before a pilot asks for them | Confirm pricing (setup RM2,000 + RM299–599/mo) survives a real pitch conversation |
| Development | Build per §B·4's phased plan | Secure one committed pilot forwarder before week 4, so real data exists to test against |
| Testing | Fix issues surfaced by the pilot's real shipment data — not synthetic test data | Sit with the pilot's ops staff while they log a real cost entry; watch where they hesitate |
| Launch | Deploy, monitor for the first month, keep the document-reminder job running reliably | Convert the pilot to paid, then approach FMFF-connected forwarders for the next 4 |
A modular monolith on managed infrastructure. Nothing here needs microservices, queues, or a separate background-job platform — the only asynchronous work is scheduled expiry-reminder checks, which a cron-style scheduled function handles.
This is a deliberate match to a solo developer's existing toolkit rather than a from-scratch evaluation — swap any row for an equivalent the developer already knows well; the architecture below does not depend on the specific vendor.
flowchart TD
subgraph Client["Browser"]
UI["Next.js UI\n(dashboard, job, documents)"]
end
subgraph App["Next.js app (Vercel)"]
SA["Server actions /\nAPI routes"]
CRON["Scheduled function:\ndaily expiry check"]
end
subgraph Data["Managed data layer"]
DB[("Postgres\nSupabase / Neon")]
STORE[("Object storage\nR2 / Supabase Storage")]
end
EMAIL["Email provider\n(Resend)"]
PAY["Payment gateway\n(CHIP / Stripe)"]
UI -->|"reads/writes"| SA
SA -->|"query/mutate"| DB
SA -->|"signed upload/download URL"| STORE
UI -->|"direct upload via\nsigned URL"| STORE
CRON -->|"query expiring docs"| DB
CRON -->|"send reminder"| EMAIL
SA -->|"checkout / webhook"| PAY
PAY -->|"payment status webhook"| SA
Single-tenant-per-row via company_id — not a separate schema per tenant. A company has many users and many clients; a client has many shipment jobs; a job has many cost line items and many documents.
erDiagram
COMPANY ||--o{ USER : employs
COMPANY ||--o{ CLIENT : "has as customer"
CLIENT ||--o{ JOB : ships_for
JOB ||--o{ COST_LINE : "costed by"
JOB ||--o{ DOCUMENT : "attached to"
USER ||--o{ JOB : creates
USER ||--o{ COST_LINE : logs
COMPANY {
uuid id PK
text name
text subscription_status
timestamptz created_at
}
USER {
uuid id PK
uuid company_id FK
text email
text role "owner | staff"
timestamptz invited_at
timestamptz joined_at
}
CLIENT {
uuid id PK
uuid company_id FK
text name
text contact_email
}
JOB {
uuid id PK
uuid company_id FK
uuid client_id FK
uuid created_by FK
text lane
numeric billed_amount
text status "open | closed"
timestamptz created_at
timestamptz closed_at
}
COST_LINE {
uuid id PK
uuid job_id FK
uuid logged_by FK
text category "freight | duty | handling | trucking | other"
numeric amount
text note
timestamptz created_at
}
DOCUMENT {
uuid id PK
uuid job_id FK
uuid uploaded_by FK
text doc_type "BL | CO | permit | other"
text storage_key
date expires_on
timestamptz reminder_sent_at
timestamptz created_at
}
JOB.billed_amount >= 0, COST_LINE.amount >= 0 — check constraints, costs are never entered negative (a correction is a note, not a negative line)USER.role restricted to owner / staff via a check constraint or enumJOB or COST_LINE is restricted at the application layer to role = owner — not expressed as a DB-level permission, since the app always connects as one service role to a managed Postgres instanceDOCUMENT.reminder_sent_at prevents the daily cron from sending the same reminder twice| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/jobs | POST | Any authenticated user in company | Body: client_id, lane, billed_amount. Returns created job |
| /api/jobs | GET | Any authenticated user in company | Query by status/client; staff sees own unless granted company-wide view |
| /api/jobs/:id | GET | Owner, or staff with access to this job | Returns job with cost_lines and documents joined, margin computed server-side |
| /api/jobs/:id | PATCH | Owner, or job creator | Update lane/billed_amount/status |
| /api/jobs/:id | DELETE | Owner only | Hard restriction enforced server-side per business rule |
| /api/jobs/:id/costs | POST | Any authenticated user with job access | Body: category, amount, note |
| /api/jobs/:id/costs/:costId | DELETE | Owner only | Hard restriction enforced server-side per business rule |
| /api/jobs/:id/documents/upload-url | POST | Any authenticated user with job access | Returns a short-lived signed PUT URL to object storage |
| /api/jobs/:id/documents | POST | Any authenticated user with job access | Records doc_type, storage_key, expires_on after client-side upload completes |
| /api/company/staff/invite | POST | Owner only | Sends invite email, creates pending USER row |
| /api/billing/webhook | POST | Payment gateway signature | Updates COMPANY.subscription_status on payment success/failure |
A single table of jobs (open by default, filterable to closed), columns: client, lane, billed amount, costs logged so far, margin, status. Margin is colored — green when positive, amber when thin, red when negative — so a forwarder scans the list and spots a problem job without opening it.
| Screen | Key actions | Empty state | Error state |
|---|---|---|---|
| Dashboard | Open a job, start a new job | "No shipments yet — create your first job" with a prominent button | Failed to load jobs: retry button, not a blank screen |
| Job detail | Log a cost, upload a document, close the job | No cost lines yet: "Margin will show once costs are logged" instead of showing RM0 misleadingly | Document upload failure shows the specific reason (file too large, unsupported type) not a generic error |
| New job | Pick client, enter lane and billed amount | No clients yet: inline "add a client" without leaving the form | Validation errors inline per field, not a toast summary |
| Settings → Staff | Invite, remove, view pending invites | "Just you so far — invite your team" | Invite-send failure names the reason (bad email, already invited) |
Modern, minimal, professional — not playful. This is a tool used by ops staff mid-task, often on a shared desktop. Favor a dense, scannable table-first layout over cards; avoid heavy illustration or marketing-style visuals inside the app itself. Keep to one accent color for interactive elements and status color (margin health) kept visually distinct from that accent.
Desktop-first — ops staff work from a desk when logging costs and reviewing documents. A usable mobile view for checking margin on the go is a reasonable P1, not a P0 requirement; don't invest in a bespoke mobile layout before a pilot customer asks for one.
Sized for one developer. The goal is a pilot forwarder logging real shipments by week 8–10, not a feature-complete platform.
Objective: auth, company/user model, and the job + cost-line data model working end to end with no UI polish.
Build: Next.js project scaffold, Postgres schema per §B·2 (company, user, client, job, cost_line tables), auth (signup/login/invite), create-job and log-cost server actions.
Dependencies: none — this is the starting point.
Deliverable / done-when: a developer can create a company, invite a second user, create a job, and log multiple cost lines against it from both accounts, with margin computed correctly.
Objective: upload, store, and track expiry for job documents.
Build: object storage bucket, signed-URL upload flow, document table + UI on the job detail screen, scheduled daily function that checks for documents approaching their expiry date and sends an email reminder.
Dependencies: Phase 1's job model.
Deliverable / done-when: a document uploaded with an expiry date 15 days out triggers a reminder email the next day the cron runs (test with a short lead time locally).
Objective: the dashboard, empty/error states from §B·3, and enough visual polish that a real forwarder can use this without hand-holding.
Build: dashboard job list with margin coloring, job detail screen layout, settings screen (staff invites, reminder lead time), responsive-enough desktop layout.
Dependencies: Phases 1–2.
Deliverable / done-when: the technical founder can walk through creating a job, logging 3 cost lines, uploading a document, and seeing the margin — entirely through the UI, no admin/database shortcuts.
Objective: onboard the first real pilot forwarder and wire up payment collection for when the pilot converts.
Build: payment gateway integration (setup fee + recurring subscription), billing status on the company record, pilot forwarder's actual company/staff set up in production.
Dependencies: Phase 3 complete and stable; a committed pilot forwarder (commercial founder's responsibility, should already be lined up by this point).
Deliverable / done-when: the pilot forwarder's team is logging real shipments in production, and a test payment can be processed end to end.
Objective: fix whatever the pilot's real usage actually surfaces — this phase is reactive by design, not a fixed feature list.
Build: whatever the pilot's first 1–2 weeks of real shipments reveal as broken or confusing. Likely candidates based on the product shape: cost-category list needs adjusting, margin display needs a "pending costs expected" indicator, document type list needs a category the pilot actually uses.
Dependencies: a pilot actively using the product in Phase 4.
Deliverable / done-when: the pilot forwarder has used the product for at least 2 consecutive weeks of real shipments without reverting to the Excel sheet.
This 8–10 week estimate assumes no major surprises in the signed-URL upload flow (the one genuinely new technical surface here) and a pilot forwarder already lined up before Phase 4 starts. If no pilot is secured by week 6, pause new feature work and treat that as the actual blocker — building further without a real user logging real shipments just produces more unvalidated guesses about cost categories and document types.