Files
hedena_gold/apps/user-app-v2/src/api/client.ts
T
s.esmaeiliandClaude Opus 5 3f549f1749 parity(apps+api): report + report/detail on the internal ledger [ref: henza_pwa views/report]
Ports "گزارش" end to end (D24), the last missing app page.

In the reference this feature computes nothing: Customer\TahesabController
proxies an external accounting system (hesab.henzagold.com and a bare IP at
31.7.68.147:3030) with TLS verification disabled, a bearer token hardcoded in
the source and a URL-embedded secret, keyed on Customer.mcode. The target has
no such system and no mcode, so per the owner's option الف this is built on our
own ledger with no external connection.

API — customer/me/report{,/detail,/balances,/:id}
- one row per CONFIRMED ledger entry with the running gold and rial balance
  after it, newest first as the reference displayed it
- only confirmed entries count, the same rule the cached
  Customer.goldDebtGrams/fiatDebtRial follow, so the statement's closing
  balance agrees with the figure shown everywhere else in the app
- fields the target has no source for (foreign currency, نام کارساخته,
  جنس فلز, the external system's internal ids) are null/zero and the UI OMITS
  the row rather than rendering a fabricated value (REP-01)

SEC-16, NOT ported: the reference's detail($id) took the factor code from the
URL and performed no ownership check at all — it never consults auth() — so any
signed-in customer could read any other customer's document by guessing a code.
Here the id is only a filter on top of a customerId scope from the token.
Tested in both directions.

Apps (both, template+script byte-identical, per-app styling only)
- ReportView with the reference's two tabs, its card rows and its «جزئیات»
  modal; ReportDocument for one document
- routes /report and /report/detail, the latter opening the second tab
- dashboard entry (v1 tile, v2 row), shown to every signed-in customer since
  the reference's hasMCode() gate has no target equivalent

Verified: build 5/5 · jest 45/45 · report 40/40 (new) ·
complete-profile 38/38 · apps smoke 43/43 · typecheck clean on all four apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:49:49 +03:30

400 lines
12 KiB
TypeScript

import type { CreateOrderDto } from '@gold-bank/shared-types';
const BASE = '/api';
const TOKEN_KEY = 'gold-bank:token';
/**
* Called when the API rejects our token. Wired up in main.ts rather than
* imported from the store, so this module stays free of a Pinia dependency
* (the client is used before Pinia is active during app bootstrap).
*/
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn;
}
/** Thrown for a 401 so callers can tell "session gone" from any other failure. */
export class UnauthorizedError extends Error {
constructor(path: string) {
super(`API ${path} rejected the session (401)`);
this.name = 'UnauthorizedError';
}
}
function readToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
// Private mode / blocked storage — treat as logged out rather than throwing.
return null;
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = readToken();
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
// Customer JWT from POST /customer/auth/verify-otp. The API derives the
// customer from this, never from an id in the URL.
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (res.status === 401) {
// Expired or revoked: drop the session and send the user to login, so no
// stale personal data stays on screen behind a dead token.
onUnauthorized?.();
throw new UnauthorizedError(path);
}
if (!res.ok) {
const body = await res.text();
throw new Error(`API ${path} failed (${res.status}): ${body}`);
}
return res.json();
}
export interface ProductVariantSummary {
id: string;
title: string;
rashWeightGrams: string;
defaultWagePercent: string;
/// On-hand stock and the "orderable at zero stock" flag. Both exist on
/// ProductVariant and are already returned by GET /catalog/products (the
/// catalog service includes every variant field) — declared here so the UI
/// can read them.
qty?: number;
isPreOrder?: boolean;
}
export interface CategorySummary {
id: string;
title: string;
parentId: string | null;
}
export interface BrandSummary {
id: string;
title: string;
}
export interface CollectionSummary {
id: string;
title: string;
description: string | null;
isActive: boolean;
}
export interface ProductSummary {
id: string;
title: string;
description: string | null;
/// Legacy Product.product_code — the human-facing code. Already returned by
/// GET /catalog/products.
productCode?: string | null;
variants: ProductVariantSummary[];
images: Array<{ url: string }>;
category: CategorySummary | null;
brand: BrandSummary | null;
collections: Array<{ collectionId: string; collection: CollectionSummary }>;
}
export interface LedgerEntrySummary {
id: string;
currency: string;
direction: string;
sourceType: string;
amount: string;
confirmed: boolean;
createdAt: string;
note: string | null;
}
export interface OrderSummary {
id: string;
orderNumber: string;
status: string;
totalGoldWeightGrams: string;
isSuspendedOverCreditLimit: boolean;
}
export interface OrderItemDetail {
id: string;
productVariantId: string;
quantity: number;
rashWeightGramsSnapshot: string;
wagePercentSnapshot: string;
discountPercentSnapshot: string;
computedWeightGrams: string;
flatRialCharge: string;
}
export interface OrderDetail {
id: string;
orderNumber: string;
status: string;
totalGoldWeightGrams: string;
wageWeightGrams: string;
flatRialCharge: string;
isSuspendedOverCreditLimit: boolean;
deliveredAt: string | null;
createdAt: string;
items: OrderItemDetail[];
}
export interface CustomerDetail {
id: string;
fullName: string;
mobile: string;
nationalCode: string;
cityId: string | null;
goldCreditLimitGrams: string;
goldDebtGrams: string;
fiatDebtRial: string;
verificationStep: string;
createdAt: string;
}
export interface GoldPriceQuote {
id: string;
karatPerMille: number;
pricePerGramRial: string;
source: string;
quotedAt: string;
}
/** One admin-uploaded home banner slide — raw shape as saved by the admin
* panel's manage/Const.vue "اسلایدر موبایل" tab (image + optional link). */
export interface AdminSlide {
image: string;
link: string;
}
/**
* D21 — a sales expert signed into the app. Ordinary customers get
* `isSalesExpert: false` and neither expert screen is shown to them.
*/
export interface ExpertIdentity {
isSalesExpert: boolean;
type: 'customer' | 'sales-expert';
}
/** One of the expert's referred customers ("انتخاب مشتری"). */
export interface ExpertCustomer {
id: string;
/** Legacy key the reference's card bound to. */
name: string;
fullName: string;
mobile: string;
phone: string | null;
store_name: string | null;
pasazh_name: string | null;
gram_level: string;
remain: string;
rial_remain: string;
active: number;
createdAt: string;
}
/** One row of "سفارش مشتریان". */
export interface ExpertChildOrder {
id: string;
orderNumber: string;
status: string;
/** Total order weight — the reference's `item.w` (ChildOrders.vue:55). */
w: string;
totalGoldWeightGrams: string;
createdAt: string;
items: OrderItemDetail[];
customer: { id: string; name: string; mobile: string } | null;
}
/**
* D24 — "گزارش", the account statement.
*
* The field names are the reference's (henza_pwa/src/type/accounting.ts), so
* the ported components bind exactly as they did. Money and weights travel as
* decimal STRINGS, never as JS numbers, so no figure is rounded in transit.
*/
export interface ReportRow {
id: number;
/** The ledger entry id; what the detail call is keyed on. */
factor_Code: string;
sh_factor: string;
tarikh: string;
/** Gold in / out, as separate positive columns. */
geram_v: string;
geram_kh: string;
rial_v: string;
rial_kh: string;
/** Running balances AFTER this row. Signed: negative means in credit. */
tahesab_T: string;
tahesab_P: string;
/** No foreign-currency ledger in the target — always zero (REP-01). */
rial_V2: string;
rial_Kh2: string;
tahesabP2: string;
sourceType: string;
note: string | null;
}
export interface ReportPage {
result: ReportRow[];
count: number;
page: number;
limit: number;
}
/** One document's detail. Fields the target cannot know are null, not invented. */
export interface ReportDetailRow {
factor_Code: string;
sh_factor: string;
tarikh: string;
vazn: string;
ayar: number | null;
no: string;
sh_sharti: string | null;
name_az: string | null;
tabdil750_v: string;
tabdil750_kh: string;
fi: string | null;
fiF: string | null;
Darsad_Talayee: string | null;
accountName: string | null;
bankName: string | null;
transferTo: string | null;
receivedAt: string | null;
note: string | null;
nO_kar: null;
jens_felez: null;
Darsad: null;
maliatDarsad: null;
}
export interface ReportBalance {
arz: string;
mandeh: string;
}
export const api = {
listProducts: () => request<ProductSummary[]>('/catalog/products'),
/**
* Step 1 of login — ports henza_server ApiMobileController::userAuth.
* Always resolves `{ sent: true }`, even for an unregistered number, so the
* endpoint cannot be used to discover which numbers are customers.
*/
requestOtp: (username: string) =>
request<{ sent: boolean }>('/customer/auth/request-otp', {
method: 'POST',
body: JSON.stringify({ username }),
}),
/**
* Step 2 — ports ApiMobileController::userVerfiy. The server compares the
* code and returns a token plus that one customer. A wrong, expired or
* already-used code is a 401.
*/
verifyOtp: (username: string, password: string) =>
request<{ token: string; customer: { id: string; mobile: string } }>(
'/customer/auth/verify-otp',
{ method: 'POST', body: JSON.stringify({ username, password }) },
),
// The three reads below identify the customer by TOKEN. They replaced
// /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from
// the URL and let anyone read anyone (SEC-08).
getMe: () => request<CustomerDetail>('/customer/me'),
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
createOrder: (dto: CreateOrderDto) => request<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/pricing/latest?karatPerMille=${karatPerMille}`),
/** GET /home/slider — public, no auth. Empty array when the admin hasn't
* configured any slides (the caller falls back to a derived hero). */
getHomeSlider: () => request<AdminSlide[]>('/home/slider'),
// ── D21: sales-expert screens ────────────────────────────────────────
// All three scope themselves on the AdminUser id inside the token; no id
// ever travels from the client. An ordinary customer gets 403 on the first
// two, and `isSalesExpert: false` from whoami so the menu entries stay hidden.
getExpertIdentity: () => request<ExpertIdentity>('/expert/whoami'),
getExpertCustomers: (search?: string | null) =>
request<ExpertCustomer[]>('/expert/my-customers', {
method: 'POST',
body: JSON.stringify({ search: search ?? null }),
}),
getExpertChildOrders: (page = 1, search?: string | null) =>
request<{ rows: ExpertChildOrder[]; total: number; page: number; limit: number }>(
'/expert/child-orders',
{ method: 'POST', body: JSON.stringify({ page, search: search ?? null }) },
),
// ── complete_profile ─────────────────────────────────────────────────
// Ports POST /major/complete (henza_server Major/CustomerController::complete)
// and the PWA's document uploads, which went through the same file/create the
// admin used but behind the customer guard (routes/api.php:95).
/**
* Uploads one document and resolves its stored id, which is what
* completeProfile sends. multipart/form-data, so it bypasses `request()` —
* that helper forces a JSON content-type, and setting it here would break
* the multipart boundary.
*/
uploadDocument: async (file: File, purpose: string): Promise<{ id: string; url: string }> => {
const body = new FormData();
body.append('file', file);
body.append('purpose', purpose);
const token = readToken();
const res = await fetch(`${BASE}/customer/me/documents`, {
method: 'POST',
// No Content-Type: the browser sets it with the boundary.
headers: token ? { Authorization: `Bearer ${token}` } : {},
body,
});
if (res.status === 401) {
onUnauthorized?.();
throw new UnauthorizedError('/customer/me/documents');
}
if (!res.ok) {
throw new Error(`آپلود فایل ناموفق بود (${res.status})`);
}
return res.json();
},
/**
* The three ids keep the reference's snake_case keys so the payload matches
* what henza_pwa sent (completeProfile.vue:136-140).
*/
completeProfile: (payload: {
business_license_image_id: string;
shop_image_id: string;
ncode_image_id: string;
}) =>
request<{ ok: boolean }>('/customer/me/complete-profile', {
method: 'POST',
body: JSON.stringify(payload),
}),
// ── report / report detail (D24) ─────────────────────────────────────
// The reference proxied an external accounting system
// (henza_server Customer/TahesabController). These read the target's own
// ledger instead — no external call — and the customer comes from the token.
getReport: (page = 1) =>
request<ReportPage>(`/customer/me/report?page=${page}`),
getReportDetailList: (page = 1) =>
request<{ result: ReportDetailRow[]; page: number; limit: number }>(
`/customer/me/report/detail?page=${page}`,
),
getReportBalances: () =>
request<{ status: number; message: string; result: ReportBalance[] }>(
'/customer/me/report/balances',
),
getReportDocument: (id: string) =>
request<{ result: ReportDetailRow[]; page: number; limit: number }>(
`/customer/me/report/${encodeURIComponent(id)}`,
),
};