import { MONTH_KEYS, resolveLangBundle } from "./langBundle" /** * Post-processing for `processes/c_invoice_generate`: stamps * `C_Invoice.period_of_performance` (the billed month only — "August 2027" / "März 2027", * month name in the partner's language) on the invoices a generation run just created — and ONLY on those, taken from * the process logs — when the invoiced order is one of the generated fulfilment fee-invoice * orders (`C_Order.isFulfillmentOrder = N`, the bookkeeping orders `fulfillment/generate-orders` * creates). `Y` is the DB default and marks orders LogYou physically fulfils — their shipment * invoices must keep an empty period of performance. * * Deliberately NO `$expand`: after the process has finished, each generated invoice is handled * with plain separate requests — GET invoice → GET its order (gate + period date) → GET the * partner (AD_Language) → PUT invoice. * * Month/year come from the order's `PeriodPerformanceDate` (the fulfillment order generator * sets it to the last day of the billed month, so every fulfilment order carries it). Without * that date nothing is written — a wrong month on a tax-relevant field is worse than an empty one. * * MUST NOT THROW: the caller's token-refresh retry would re-run the whole generation * process and create the invoices a second time. Every failure is collected in `errors`. */ export type PeriodOfPerformanceResult = { updated: Array<{ invoiceId: number, documentNo: string, text: string }> skipped: Array<{ invoiceId?: number, documentNo?: string, reason: string }> errors: Array<{ invoiceId?: number, documentNo?: string, error: string }> } const MAX_LENGTH = 150 // ad_column FieldLength of C_Invoice.period_of_performance /** GET with one retry — the prod REST layer occasionally drops a connection. */ const getWithRetry = async (event: any, token: string, url: string, attempts = 2): Promise => { let lastErr: any for (let i = 0; i < attempts; i++) { try { return await event.context.fetch(url, 'GET', token, null) } catch (err: any) { lastErr = err const status = Number(err?.status ?? err?.statusCode ?? err?.response?.status ?? 0) if (status === 404) break // a wrong id is not transient } } throw lastErr } /** DocumentNo from a process log line — "Invoice Processed: 26100379", "Rechnung …: 26100379" or a bare number. */ const docNoFromLog = (log: any): string => { const s = String(log?.msg ?? log ?? '').trim() if (/^\d+$/.test(s)) return s const m = s.match(/:\s*(\d+)\s*$/) return m ? m[1] : '' } /** Record id carried by the log entry (P_ID / Record_ID), 0 when absent. */ const recordIdFromLog = (log: any): number => { const cand = Number(log?.recordId ?? log?.Record_ID ?? log?.id ?? 0) return Number.isFinite(cand) && cand > 0 ? cand : 0 } const parseYearMonth = (val: any): { year: number, month: number } | null => { const m = String(val ?? '').match(/^(\d{4})-(\d{2})-(\d{2})/) if (!m) return null const year = Number(m[1]) const month = Number(m[2]) return month >= 1 && month <= 12 ? { year, month } : null } /** "August 2027" — just the performance month, no label; the month name follows the partner language. */ export const buildPeriodOfPerformanceText = (t: Record, year: number, month: number): string => `${t[MONTH_KEYS[month - 1]] ?? month} ${year}`.substring(0, MAX_LENGTH) const fetchInvoice = async (event: any, token: string, log: any): Promise => { const docNo = docNoFromLog(log) const recordId = recordIdFromLog(log) if (recordId) { try { const inv: any = await getWithRetry(event, token, `models/c_invoice/${recordId}`) if (inv?.id && (!docNo || String(inv.DocumentNo) === docNo)) return inv } catch { // Log id is not (this) invoice's id — fall through to the DocumentNo lookup. } } if (docNo) { const filter = encodeURIComponent(`DocumentNo eq '${docNo}' and IsSOTrx eq true`) const orderby = encodeURIComponent('Created desc') const res: any = await getWithRetry( event, token, `models/c_invoice?$filter=${filter}&$orderby=${orderby}&$top=1` ) return res?.records?.[0] ?? null } return null } const errorText = (err: any): string => err?.data?.detail ?? err?.data?.title ?? err?.data?.message ?? err?.message ?? String(err) export const applyPeriodOfPerformance = async ( event: any, token: string, logs: any[], sessionLang: string ): Promise => { const result: PeriodOfPerformanceResult = { updated: [], skipped: [], errors: [] } const seen = new Set() const seenLogKeys = new Set() for (const log of Array.isArray(logs) ? logs : []) { // A repeated log line for the same document must not cost another round of requests. const logKey = `${recordIdFromLog(log)}|${docNoFromLog(log)}` if (logKey !== '0|' && seenLogKeys.has(logKey)) continue seenLogKeys.add(logKey) let inv: any = null try { inv = await fetchInvoice(event, token, log) if (!inv?.id) { result.skipped.push({ documentNo: docNoFromLog(log) || undefined, reason: 'invoice-not-found' }) continue } if (seen.has(inv.id)) continue seen.add(inv.id) const documentNo = String(inv.DocumentNo ?? '') const orderId = inv.C_Order_ID?.id if (!orderId) { result.skipped.push({ invoiceId: inv.id, documentNo, reason: 'no-order' }) continue } // Separate request: the order carries the gate and the period date. const order: any = await getWithRetry(event, token, `models/c_order/${orderId}`) // Gate: only the generator's bookkeeping orders (isFulfillmentOrder = N). Every order // LogYou physically fulfils carries the DB default Y and is skipped. An absent value is // treated as Y as well — never stamp a tax-relevant field on an unknown order. const isFulfillmentOrder = order?.isFulfillmentOrder ?? order?.IsFulfillmentOrder if (isFulfillmentOrder !== false && isFulfillmentOrder !== 'N') { result.skipped.push({ invoiceId: inv.id, documentNo, reason: 'not-fulfillment-invoice-order' }) continue } const period = parseYearMonth(order?.PeriodPerformanceDate) if (!period) { result.skipped.push({ invoiceId: inv.id, documentNo, reason: 'no-period-date' }) continue } // Separate request: the partner's AD_Language decides the text (de_DE → German, // en_US → English); the session language is used only when the partner has none set. const partnerId = inv.C_BPartner_ID?.id ?? order?.C_BPartner_ID?.id let partnerLanguage: any if (partnerId) { try { const partner: any = await getWithRetry(event, token, `models/c_bpartner/${partnerId}`) partnerLanguage = partner?.AD_Language } catch { // partner unreadable → session language below } } const t = resolveLangBundle(partnerLanguage, sessionLang) const text = buildPeriodOfPerformanceText(t, period.year, period.month) if (String(inv.period_of_performance ?? '') === text) { result.skipped.push({ invoiceId: inv.id, documentNo, reason: 'already-set' }) continue } // Column is IsAlwaysUpdateable, so this works on completed (CO) invoices too. await event.context.fetch(`models/c_invoice/${inv.id}`, 'PUT', token, { period_of_performance: text }) result.updated.push({ invoiceId: inv.id, documentNo, text }) } catch (err: any) { result.errors.push({ invoiceId: inv?.id, documentNo: inv?.DocumentNo ? String(inv.DocumentNo) : (docNoFromLog(log) || undefined), error: errorText(err) }) } } return result }