import type { CreateOrderDto } from '@gold-bank/shared-types'; const BASE = '/api'; const TOKEN_KEY = 'gold-bank:token'; /** Must match stores/session.ts — the act-as target, if any. */ const ACTING_AS_KEY = 'gold-bank:actingAs'; /** * 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; } } /** * «ورود به حساب مشتری» — the customer a sales expert is working inside. * * Read from storage rather than the store for the same reason as the token: this * module must not depend on Pinia. The server treats this as a request, not a * fact: it re-checks on every call that the customer is actually referred by * this expert, so a forged value is a 403 and reveals nothing. */ function readActingAsId(): string | null { try { const raw = sessionStorage.getItem(ACTING_AS_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as { id?: unknown }; return typeof parsed?.id === 'string' && parsed.id ? parsed.id : null; } catch { return null; } } async function request(path: string, options?: RequestInit): Promise { const token = readToken(); const actingAs = readActingAsId(); 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}` } : {}), // Sent on every request while impersonating, which is what makes the whole // app render from the customer's point of view — the reference achieved // the same by swapping the bearer token in its axios interceptor // (henza_pwa plugins/axios.ts:18 via getUserToken()). ...(actingAs ? { 'X-Act-As-Customer': actingAs } : {}), ...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 | null; 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; } /** GET /customer/me/accounting-balance. Signed like the app: positive = owes. */ export interface AccountingBalance { source: 'accounting' | 'stored' | 'none' gold: string | null rial: string | null at: string | null provider: string | null } /** POST /customer/me/wage-quote — one variant's making charge for the signed-in customer. */ export interface WageQuote { variantId: string variantPercent: string customerPercent: string promotionPercent: string finalPercent: string } function iamAppUrl(path: string) { const base = import.meta.env.BASE_URL || '/' const prefix = base.endsWith('/') ? base : `${base}/` return `${window.location.origin}${prefix}${path.replace(/^\//, '')}` } export const api = { listProducts: () => request('/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 }; iamBootstrapUrl?: string }>( '/customer/auth/verify-otp', { method: 'POST', body: JSON.stringify({ username, password }) }, ), ssoStart: () => request<{ enabled: boolean; authorize_url: string | null }>( `/customer/auth/sso/start?redirect_uri=${encodeURIComponent(iamAppUrl('auth/iam/callback'))}`, ), ssoCallback: (code: string, state: string) => request<{ token: string; customer: { id: string; mobile: string } }>('/customer/auth/sso/callback', { method: 'POST', body: JSON.stringify({ code, state }), }), ssoLogoutUrl: () => request<{ logout_url: string | null }>( `/customer/auth/sso/logout-url?return_url=${encodeURIComponent(iamAppUrl(''))}`, ), endSharedSession: () => request<{ ended: number }>('/customer/auth/logout', { method: 'POST', body: '{}' }), // 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('/customer/me'), /** D27 §12 — this customer's balance, read from the accounting software on every call. */ getAccountingBalance: () => request('/customer/me/accounting-balance'), getMyOrders: () => request('/customer/me/orders'), getMyLedgerEntries: () => request('/customer/me/ledger-entries'), /** The making charge the server will charge for these variants (MON-08). */ getWageQuotes: (variantIds: string[]) => request('/customer/me/wage-quote', { method: 'POST', body: JSON.stringify({ variantIds }) }), /** Favourite product ids, newest first — legacy get-favorites (FAV-01). */ getFavorites: () => request<{ ids: string[] }>('/customer/me/favorites'), /** Legacy add-to-fav: adds or removes; answers the new state. */ toggleFavorite: (productId: string) => request<{ favorite: boolean }>('/customer/me/favorites/toggle', { method: 'POST', body: JSON.stringify({ productId }), }), createOrder: (dto: CreateOrderDto) => request('/orders', { method: 'POST', body: JSON.stringify(dto) }), getLatestGoldPrice: (karatPerMille = 750) => request(`/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('/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('/expert/whoami'), getExpertCustomers: (search?: string | null) => request('/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(); // This route bypasses `request()`, so the act-as header has to be repeated // here — without it an expert uploading a customer's documents would file // them against their OWN account, silently and with no error. const actingAs = readActingAsId(); 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}` } : {}), ...(actingAs ? { 'X-Act-As-Customer': actingAs } : {}), }, 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(`/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)}`, ), };