// Merchant Onboarding orchestrator — creates the full set of linked iDempiere records for // a new merchant in dependency order, STOPS at the first failing step, and returns a // structured report of what was created / failed / skipped (no auto-rollback). // // Deliberately does NOT use the boilerplate "retry the whole handleFunc on error" pattern: // re-running would duplicate already-created records. Instead a single token is obtained // once and each iDempiere call is wrapped so that a 401 triggers ONE shared refresh + // retry of just that call — earlier steps are never re-run. // // Steps (see reference/recipes / the approved plan): // 1 C_BPartner (under the operator org) // 2 C_Location (merchant address) // 3 C_BPartner_Location (link bpartner <-> location, ship/pay/bill/remit) // 4 AD_Org (the new merchant org, linked to the bpartner) // 4b PUT C_BPartner (set Accounting_AD_Org_ID = the new org) // 5 AD_User (login user under the new org) // 6 M_Warehouse (under new org, C_Location = operator org's ship-to location) // 7 M_Locator ("Wareneingang" pallet locator, default) // 8 AD_Role (clone of the template role via copyRoleHelper) // 9 AD_User_Roles (assign the user to the new role) import fetchHelper from "../../../utils/fetchHelper" import refreshTokenHelper from "../../../utils/refreshTokenHelper" import copyRoleHelper from "../../../utils/copyRoleHelper" const extractErr = (e: any): string => { const d = e?.data ?? e const msg = d?.detail || d?.title || d?.summary || d?.message || e?.message if (msg) return String(msg) try { return JSON.stringify(d) } catch { return 'Unknown error' } } const isAuthError = (e: any): boolean => { const s = e?.statusCode ?? e?.status ?? e?.response?.status return Number(s) === 401 } // Transient gateway/server errors worth a short retry (the iDempiere REST layer can // briefly 502/503 under load or during a restart). const isTransientError = (e: any): boolean => { const s = e?.statusCode ?? e?.status ?? e?.response?.status return [502, 503, 504].includes(Number(s)) } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) // Coerce a numeric-ish input to a number, or undefined when empty/blank. const num = (v: any): number | undefined => { if (v === undefined || v === null || v === '') return undefined const n = Number(v) return Number.isNaN(n) ? undefined : n } const str = (v: any): string | undefined => { if (v === undefined || v === null) return undefined const s = String(v).trim() return s === '' ? undefined : s } const bool = (v: any): boolean => v === true || v === 'true' || v === 'Y' || v === 1 export default defineEventHandler(async (event) => { const config = useRuntimeConfig() const body = await readBody(event) const operatorOrgId = getCookie(event, 'logship_organization_id') // ---- single token + one-shot refresh-on-401 ----------------------------------------- let token = await getTokenHelper(event) let refreshing: Promise | null = null const doRefresh = async (): Promise => { if (!refreshing) refreshing = refreshTokenHelper(event).finally(() => { refreshing = null }) as Promise return refreshing } const apiCall = async (url: string, method: string, payload: any): Promise => { let lastErr: any for (let attempt = 0; attempt < 3; attempt++) { try { return await fetchHelper(event, url, method, token, payload) } catch (e: any) { lastErr = e if (isAuthError(e)) { token = await doRefresh(); continue } if (isTransientError(e) && attempt < 2) { await sleep(600 * (attempt + 1)); continue } throw e } } throw lastErr } // ---- report + step runner ----------------------------------------------------------- const report: any[] = [] const ctx: any = {} let failed = false const step = async (meta: any, fn: () => Promise) => { if (failed) { report.push({ ...meta, status: 'skipped', id: null, message: '' }) return null } try { const res = await fn() report.push({ ...meta, status: 'success', id: res?.id ?? res?.newRoleId ?? null, message: '', detail: res?._detail }) return res } catch (e: any) { failed = true report.push({ ...meta, status: 'error', id: null, message: extractErr(e) }) return null } } // ---- field mapping ------------------------------------------------------------------ const companyName = str(body.companyName) const ceoName = str(body.ceoName) const shortName = str(body.shortName) const isFulfillment = bool(body.isFulfillmentCustomer) // A01 or A03: Name = company (else ceo); Name2 = ceo only when a company name exists. const bpName = companyName || ceoName || shortName || '' const bpName2 = companyName ? (ceoName || '') : '' // ---- Step 1: C_BPartner (under the operator org) ------------------------------------ const bp = await step( { step: 1, key: 'bpartner', entity: 'C_BPartner', label: 'Business Partner', name: bpName }, async () => { const payload: any = { AD_Org_ID: { id: operatorOrgId, tableName: 'AD_Org' }, isActive: true, name: bpName, name2: bpName2, // Value intentionally omitted — iDempiere auto-fills C_BPartner.Value. isCustomer: true, isVendor: false, isFulfillmentCustomer: isFulfillment, tableName: 'C_Bpartner', } const taxId = str(body.taxId); if (taxId !== undefined) payload.taxID = taxId const email = str(body.companyEmail); if (email !== undefined) payload.EMail = email const hrg = str(body.hrgNumber); if (hrg !== undefined) payload.hrg_number = hrg const contract = str(body.contractedSigned); if (contract !== undefined) payload.contractedSigned = contract // fulfillment pricing const baseprice = num(body.fulfillmentOrderBaseprice); if (baseprice !== undefined) payload.fulfillment_order_baseprice = baseprice const pickprice = num(body.fulfillmentOrderPickprice); if (pickprice !== undefined) payload.fulfillment_order_pickprice = pickprice const qtyPickfree = num(body.fulfillmentOrderQtyPickfree); if (qtyPickfree !== undefined) payload.fulfillment_order_qty_pickfree = qtyPickfree const retBase = num(body.fulfillmentOrderReturnBaseprice); if (retBase !== undefined) payload.fulfillment_order_return_baseprice = retBase const retPick = num(body.fulfillmentOrderReturnPickprice); if (retPick !== undefined) payload.fulfillment_order_return_pickprice = retPick const shelfLarge = num(body.shelfRentLargePrice); if (shelfLarge !== undefined) payload.Shelf_Rent_Large_Price = shelfLarge const shelfSmall = num(body.shelfRentSmallPrice); if (shelfSmall !== undefined) payload.Shelf_Rent_Small_Price = shelfSmall const palett = num(body.rentPalettSpacePrice); if (palett !== undefined) payload.rent_palett_space_price = palett const hourly = num(body.fulfillmentServiceHourlyRate); if (hourly !== undefined) payload.fulfillment_service_hourly_rate = hourly // Parcel packaging costs (incl. wrapping paper) per volume size + ZUGFeRD opt-in. const pkgS = num(body.paketSPrice); if (pkgS !== undefined) payload.M_Product_Paket_2_S_Price = pkgS const pkgM = num(body.paketMPrice); if (pkgM !== undefined) payload.M_Product_Paket_3_M_Price = pkgM const pkgL = num(body.paketLPrice); if (pkgL !== undefined) payload.M_Product_Paket_4_L_Price = pkgL const pkgXL = num(body.paketXLPrice); if (pkgXL !== undefined) payload.M_Product_Paket_5_XL_Price = pkgXL if (bool(body.isZugPferdInvoice)) payload.isZugPferdInvoice = true // accounting const rentStarts = str(body.accoutingWarehouseRentStarts); if (rentStarts !== undefined) payload.AccoutingWarehouseRentStarts = rentStarts if (bool(body.isAccountingWarehouseVolume)) payload.isAccountingWarehouseVolume = true if (bool(body.isAccountLogShip)) payload.isAccountLogShip = true const monthly = num(body.logshipMonthlyFee); if (monthly !== undefined) payload.logshipmonthlyfee = monthly if (bool(body.isAllowFeeReportDownload)) payload.isAllowFeeReportDownload = true const returnEmail = str(body.returnEmail); if (returnEmail !== undefined) payload.return_email = returnEmail // FK selections const groupId = num(body.partnerGroupId); if (groupId !== undefined) payload.C_BP_Group_ID = { id: groupId, tableName: 'C_BP_Group' } const priceListId = num(body.priceListId); if (priceListId !== undefined) payload.M_PriceList_ID = { id: priceListId, tableName: 'M_PriceList' } const deliveryViaRuleId = str(body.deliveryViaRuleId); if (deliveryViaRuleId !== undefined) payload.DeliveryViaRule = { id: deliveryViaRuleId } const shipperId = num(body.shipperOrderDefaultId); if (shipperId !== undefined) payload.M_Shipper_OrderDefault_ID = { id: shipperId, tableName: 'M_Shipper' } const res = await apiCall('models/c_bpartner', 'POST', payload) if (!res?.id) throw new Error(res?.summary || res?.message || 'No id returned for C_BPartner') return res } ) if (bp) ctx.bpartnerId = bp.id // ---- Step 2: C_Location ------------------------------------------------------------- const loc = await step( { step: 2, key: 'location', entity: 'C_Location', label: 'Address', name: `${str(body.street) || ''}, ${str(body.zip) || ''} ${str(body.city) || ''}` }, async () => { const payload: any = { AD_Org_ID: { id: operatorOrgId, tableName: 'AD_Org' }, isActive: true, address1: str(body.street) || '', city: str(body.city) || '', postal: str(body.zip) || '', isValid: true, tableName: 'C_Location', } const countryId = num(body.countryId) if (countryId !== undefined) payload.C_Country_ID = { id: countryId, tableName: 'C_Country' } const res = await apiCall('models/c_location', 'POST', payload) if (!res?.id) throw new Error(res?.summary || 'No id returned for C_Location') return res } ) if (loc) ctx.locationId = loc.id // ---- Step 3: C_BPartner_Location (link) --------------------------------------------- const bpLoc = await step( { step: 3, key: 'bpLocation', entity: 'C_BPartner_Location', label: 'Partner Location', name: bpName }, async () => { const payload: any = { AD_Org_ID: { id: operatorOrgId, tableName: 'AD_Org' }, isActive: true, name: bpName, isBillTo: true, isShipTo: true, isPayFrom: true, isRemitTo: true, C_BPartner_ID: { id: ctx.bpartnerId, tableName: 'C_BPartner' }, C_Location_ID: { id: ctx.locationId, tableName: 'C_Location' }, tableName: 'C_BPartner_Location', } const phone = str(body.phoneWired); if (phone !== undefined) payload.phone = phone const phone2 = str(body.phoneMobile); if (phone2 !== undefined) payload.phone2 = phone2 // CEO name → Name2 on the partner location (when a distinct CEO name was entered). if (ceoName && ceoName !== bpName) payload.name2 = ceoName const res = await apiCall('models/c_bpartner_location', 'POST', payload) if (!res?.id) throw new Error(res?.summary || 'No id returned for C_BPartner_Location') return res } ) if (bpLoc) ctx.bpLocationId = bpLoc.id // ---- Step 4: AD_Org ----------------------------------------------------------------- const org = await step( { step: 4, key: 'org', entity: 'AD_Org', label: 'Organization', name: shortName }, async () => { const payload: any = { isActive: true, name: shortName, value: shortName, isSummary: false, C_BPartner_ID: { id: ctx.bpartnerId, tableName: 'C_BPartner' }, AD_Org_ID: { id: 0, tableName: 'AD_Org' }, tableName: 'AD_Org', } const res = await apiCall('models/ad_org', 'POST', payload) if (!res?.id) throw new Error(res?.summary || 'No id returned for AD_Org') return res } ) if (org) ctx.newOrgId = org.id // ---- Step 4b: PUT C_BPartner -> Accounting_AD_Org_ID = new org ---------------------- await step( { step: 4, key: 'bpartnerAccountingOrg', entity: 'C_BPartner', label: 'Link Accounting Org', name: bpName }, async () => { const res = await apiCall(`models/c_bpartner/${ctx.bpartnerId}`, 'PUT', { Accounting_AD_Org_ID: { id: ctx.newOrgId, tableName: 'AD_Org' }, tableName: 'C_Bpartner', }) return { id: ctx.bpartnerId, _detail: `Accounting_AD_Org_ID = ${ctx.newOrgId}` } } ) // ---- Step 5: AD_User ---------------------------------------------------------------- // Note: A11 e-mail is set on BOTH the bpartner and the user per spec. If the iDempiere // instance enforces unique user e-mail this may fail (then the report stops here). const user = await step( { step: 5, key: 'user', entity: 'AD_User', label: 'Login User', name: shortName }, async () => { const payload: any = { AD_Org_ID: { id: ctx.newOrgId, tableName: 'AD_Org' }, isActive: true, name: shortName, value: shortName, password: str(body.password) || '', eMail: str(body.companyEmail) || '', C_BPartner_ID: { id: ctx.bpartnerId, tableName: 'C_BPartner' }, tableName: 'AD_User', } const res = await apiCall('models/ad_user', 'POST', payload) if (!res?.id) throw new Error(res?.summary || 'No id returned for AD_User') return res } ) if (user) ctx.userId = user.id // ---- Step 6: M_Warehouse (location = operator org's ship-to location) --------------- const warehouse = await step( { step: 6, key: 'warehouse', entity: 'M_Warehouse', label: 'Warehouse', name: shortName }, async () => { // resolve operator org -> bpartner -> ship-to location let opLocationId: any = null const orgRec = await apiCall(`models/ad_org/${operatorOrgId}?$select=C_BPartner_ID`, 'GET', null) const opBpId = orgRec?.C_BPartner_ID?.id if (opBpId) { const shipTo = await apiCall(`models/c_bpartner_location?$filter=C_BPartner_ID eq ${opBpId} and IsShipTo eq true&$top=1`, 'GET', null) opLocationId = shipTo?.records?.[0]?.C_Location_ID?.id if (!opLocationId) { const anyLoc = await apiCall(`models/c_bpartner_location?$filter=C_BPartner_ID eq ${opBpId}&$top=1`, 'GET', null) opLocationId = anyLoc?.records?.[0]?.C_Location_ID?.id } } if (!opLocationId) throw new Error('Could not resolve the operator org warehouse location (C_Location_ID)') ctx.warehouseLocationId = opLocationId const payload: any = { AD_Org_ID: { id: ctx.newOrgId, tableName: 'AD_Org' }, isActive: true, name: shortName, // Value intentionally omitted — iDempiere auto-fills M_Warehouse.Value. separator: '-', isInTransit: false, isDisallowNegativeInv: false, C_Location_ID: { id: opLocationId, tableName: 'C_Location' }, tableName: 'M_Warehouse', } const res = await apiCall('models/m_warehouse', 'POST', payload) if (!res?.id) throw new Error(res?.summary || 'No id returned for M_Warehouse') return res } ) if (warehouse) ctx.warehouseId = warehouse.id // ---- Step 7: M_Locator ("Wareneingang") -------------------------------------------- const locator = await step( { step: 7, key: 'locator', entity: 'M_Locator', label: 'Locator (Wareneingang)', name: 'Wareneingang' }, async () => { const payload: any = { AD_Org_ID: { id: ctx.newOrgId, tableName: 'AD_Org' }, isActive: true, value: 'Wareneingang', priorityNo: 50, X: '0', Y: '0', Z: '0', isDefault: true, M_Warehouse_ID: { id: ctx.warehouseId, tableName: 'M_Warehouse' }, M_LocatorType_ID: { id: Number(config.merchantPalettLocatorTypeId), tableName: 'M_LocatorType' }, tableName: 'm_locator', } const res = await apiCall('models/m_locator', 'POST', payload) if (!res?.id) throw new Error(res?.summary || 'No id returned for M_Locator') // Set the just-created locator as the warehouse's reserve locator (best-effort: the // locator already exists, so a failed link is reported but does not fail the step). let reserveNote = '' try { await apiCall(`models/m_warehouse/${ctx.warehouseId}`, 'PUT', { M_ReserveLocator_ID: { id: res.id, tableName: 'M_Locator' }, tableName: 'M_Warehouse', }) reserveNote = ' · set as warehouse reserve locator' } catch (e: any) { reserveNote = ' · reserve-locator link failed: ' + extractErr(e) } res._detail = 'Wareneingang' + reserveNote return res } ) if (locator) ctx.locatorId = locator.id // ---- Step 8: Copy Role -------------------------------------------------------------- const role = await step( { step: 8, key: 'role', entity: 'AD_Role', label: 'Role', name: shortName }, async () => { const result = await copyRoleHelper(apiCall, { sourceRoleId: config.merchantTemplateRoleId, newOrgId: ctx.newOrgId, newRoleName: shortName || '', }) return { id: result.newRoleId, newRoleId: result.newRoleId, _detail: result.orgAccessOk ? 'Auto-provisioned by iDempiere (Fulfillment-Customer access); org access OK' : 'Role created; org access not confirmed' + (result.orgAccessError ? ` (${result.orgAccessError})` : ''), copyResult: result, } } ) if (role) { ctx.newRoleId = role.newRoleId ctx.roleCopy = role.copyResult } // ---- Step 9: AD_User_Roles ---------------------------------------------------------- const assignment = await step( { step: 9, key: 'userRole', entity: 'AD_User_Roles', label: 'User → Role assignment', name: shortName }, async () => { const res = await apiCall('models/ad_user_roles', 'POST', { AD_Org_ID: { id: ctx.newOrgId, tableName: 'AD_Org' }, AD_User_ID: { id: ctx.userId, tableName: 'AD_User' }, AD_Role_ID: { id: ctx.newRoleId, tableName: 'AD_Role' }, isActive: true, tableName: 'AD_User_Roles', }) // AD_User_Roles has a composite PK (no surrogate id): a successful POST returns the // record with a `uid` but no `id`, so confirm via uid (checking id alone false-fails). if (!res?.uid && !res?.id) throw new Error(res?.summary || res?.message || 'AD_User_Roles not created') return res } ) if (assignment) ctx.userRoleId = assignment.id // ---- Step 10: org-access isolation cleanup ------------------------------------------ // Creating the new org makes iDempiere auto-grant org-access to EVERY existing role, so // other merchants could otherwise see the new org. Remove the grants for OTHER merchant // roles (FrontendMenu = 'c'), keeping the new merchant role + all admin/staff roles (so // your team can still manage the merchant). Deletes are by `uid` — AD_Role_OrgAccess has // a composite PK (no surrogate id). Runs whenever the org + new role exist (even if a // prior step failed), since the leak exists the moment the org is created. if (ctx.newOrgId && ctx.newRoleId) { let removed = 0 let kept = 0 const errs: string[] = [] try { const res: any = await apiCall( `models/ad_role_orgaccess?$filter=AD_Org_ID eq ${ctx.newOrgId}&$expand=AD_Role_ID&$top=1000`, 'GET', null ) const rows: any[] = res?.records || [] for (const r of rows) { const roleId = r.AD_Role_ID?.id const frontendMenu = r.AD_Role_ID?.FrontendMenu?.id ?? r.AD_Role_ID?.FrontendMenu const isNewRole = String(roleId) === String(ctx.newRoleId) const isMerchant = String(frontendMenu) === 'c' if (isNewRole || !isMerchant || !r.uid) { kept++; continue } try { await apiCall(`models/ad_role_orgaccess/${r.uid}`, 'DELETE', null); removed++ } catch (e: any) { errs.push(extractErr(e)) } } report.push({ step: 10, key: 'orgAccessCleanup', entity: 'AD_Role_OrgAccess', label: 'Org-access isolation', status: (removed === 0 && errs.length > 0) ? 'error' : 'success', id: null, message: errs.length ? errs.slice(0, 3).join('; ') : '', detail: `removed ${removed} other-merchant grant(s), kept ${kept} (new role + admin/staff)`, }) } catch (e: any) { report.push({ step: 10, key: 'orgAccessCleanup', entity: 'AD_Role_OrgAccess', label: 'Org-access isolation', status: 'error', id: null, message: extractErr(e), detail: '', }) } } const failedStep = report.find((r) => r.status === 'error') || null return { status: failed ? 500 : 200, message: failed ? `Onboarding stopped at step ${failedStep?.step} (${failedStep?.entity})` : '', report, ctx, failedStep, meta: { createdAt: new Date().toISOString(), loginUrl: 'https://app.logship.de', shortName, companyName: bpName, }, } })