import { string } from 'alga-js' import refreshTokenHelper from "../../utils/refreshTokenHelper" import forceLogoutHelper from "../../utils/forceLogoutHelper" import errorHandlingHelper from "../../utils/errorHandlingHelper" import fetchHelper from "../../utils/fetchHelper" import maybeZugferdPdf from "../../utils/zugferd/maybeZugferdPdf" // Amazon SP-API endpoints const LWA_TOKEN_URL = 'https://api.amazon.com/auth/o2/token' const SP_API_BASE_URL = 'https://sellingpartnerapi-eu.amazon.com' // EU Marketplace IDs const MARKETPLACE_IDS: Record = { 'DE': 'A1PA6795UKMFR9', 'FR': 'A13V1IB3VIYZZH', 'IT': 'APJ6JRA9NG5V4', 'ES': 'A1RKKUPIHCS9HS', 'NL': 'A1805IZSGTT6HS', 'BE': 'AMEN7PMS3EDWL', 'UK': 'A1F83G8C2ARO7P', 'GB': 'A1F83G8C2ARO7P', 'PL': 'A1C3SOZRARQ6R3', 'SE': 'A2NODRKZP88ZB9', 'AT': 'A2CVHYRTWLQO9T' } // Set of all known Amazon SP-API marketplaceIds (raw IDs, no lookup needed) const KNOWN_MARKETPLACE_IDS = new Set(Object.values(MARKETPLACE_IDS)) // Resolve an Amazon marketplaceId from c_ordersource. // Tries Marketplace.identifier (raw marketplaceId, country code, or suffixed), // then falls back to parsing the Description URL (e.g. "https://sellercentral.amazon.de/..." -> DE). // Returns null when no match is found. const resolveMarketplaceId = ( identifier: string | null | undefined, description: string | null | undefined = null ): string | null => { const raw = String(identifier ?? '').trim() if (raw) { if (KNOWN_MARKETPLACE_IDS.has(raw)) return raw const upper = raw.toUpperCase() if (MARKETPLACE_IDS[upper]) return MARKETPLACE_IDS[upper] const suffixMatch = upper.match(/[-._ ]([A-Z]{2})$/) if (suffixMatch && MARKETPLACE_IDS[suffixMatch[1]]) return MARKETPLACE_IDS[suffixMatch[1]] } const desc = String(description ?? '') if (desc) { const urlMatch = desc.match(/amazon\.([a-z]{2,3}(?:\.[a-z]{2})?)/i) if (urlMatch) { const tld = urlMatch[1].toLowerCase() const tldToCountry: Record = { 'de': 'DE', 'fr': 'FR', 'it': 'IT', 'es': 'ES', 'nl': 'NL', 'be': 'BE', 'pl': 'PL', 'se': 'SE', 'at': 'AT', 'co.uk': 'UK', 'uk': 'UK' } const country = tldToCountry[tld] if (country && MARKETPLACE_IDS[country]) return MARKETPLACE_IDS[country] } } return null } // Log collector for debugging const createLogger = () => { const logs: string[] = [] return { log: (message: string) => { const timestamp = new Date().toISOString() logs.push(`[${timestamp}] ${message}`) }, getLogs: () => logs } } // Get LWA access token using refresh token const getAmazonAccessToken = async (clientId: string, clientSecret: string, refreshToken: string, logger?: ReturnType): Promise => { logger?.log('Requesting LWA access token...') const response = await fetch(LWA_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, client_secret: clientSecret }) }) if (!response.ok) { const errorText = await response.text() logger?.log(`LWA token error (${response.status}): ${errorText}`) throw new Error(`Failed to get Amazon access token (${response.status}): ${errorText}`) } const data: any = await response.json() logger?.log('LWA access token obtained successfully') return data.access_token } // Create feed document (get upload URL) const createFeedDocument = async (accessToken: string, logger?: ReturnType): Promise<{ feedDocumentId: string, url: string }> => { logger?.log('Creating feed document...') const response = await fetch(`${SP_API_BASE_URL}/feeds/2021-06-30/documents`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-amz-access-token': accessToken }, body: JSON.stringify({ contentType: 'application/pdf' }) }) if (!response.ok) { const errorText = await response.text() logger?.log(`createFeedDocument error (${response.status}): ${errorText}`) throw new Error(`Failed to create feed document (${response.status}): ${errorText}`) } const data: any = await response.json() logger?.log(`Feed document created: ${data.feedDocumentId}`) return { feedDocumentId: data.feedDocumentId, url: data.url } } // Upload PDF to the pre-signed URL const uploadPdfToAmazon = async (url: string, pdfBuffer: Buffer, logger?: ReturnType): Promise => { logger?.log(`Uploading PDF (${pdfBuffer.length} bytes) to pre-signed URL...`) const response = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/pdf' }, body: pdfBuffer }) if (!response.ok) { const errorText = await response.text() logger?.log(`PDF upload error (${response.status}): ${errorText}`) throw new Error(`Failed to upload PDF to Amazon (${response.status}): ${errorText}`) } logger?.log('PDF uploaded successfully') } // Create the feed with UPLOAD_VAT_INVOICE type const createFeed = async ( accessToken: string, feedDocumentId: string, amazonOrderId: string, invoiceNumber: string, documentType: string, marketplaceId: string, logger?: ReturnType ): Promise => { logger?.log(`Creating feed: orderID=${amazonOrderId}, invoice=${invoiceNumber}, type=${documentType}, marketplace=${marketplaceId}`) const feedOptions: Record = { 'metadata:orderid': amazonOrderId, 'metadata:invoicenumber': invoiceNumber, 'metadata:documenttype': documentType } const response = await fetch(`${SP_API_BASE_URL}/feeds/2021-06-30/feeds`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-amz-access-token': accessToken }, body: JSON.stringify({ feedType: 'UPLOAD_VAT_INVOICE', marketplaceIds: [marketplaceId], inputFeedDocumentId: feedDocumentId, feedOptions }) }) if (!response.ok) { const errorText = await response.text() logger?.log(`createFeed error (${response.status}): ${errorText}`) throw new Error(`Failed to create feed (${response.status}): ${errorText}`) } const data: any = await response.json() logger?.log(`Feed created: ${data.feedId}`) return data } const handleFunc = async (event: any, authToken: any = null) => { const logger = createLogger() let data: any = {} const token = authToken ?? await getTokenHelper(event) const body = await readBody(event) const invoiceIds: number[] = body.ids || [] logger.log(`Starting Amazon invoice upload for IDs: ${JSON.stringify(invoiceIds)}`) if (!invoiceIds.length) { return { status: 400, message: 'No invoice IDs provided', logs: logger.getLogs() } } const errors: string[] = [] const skipped: string[] = [] let uploadedCount = 0 // Cache for order source credentials (to avoid re-fetching for same order source) const orderSourceCache: Record = {} // Cache for LWA access tokens per order source const accessTokenCache: Record = {} for (const invoiceId of invoiceIds) { try { logger.log(`--- Processing invoice ID: ${invoiceId} ---`) // Fetch invoice with order details const invoice: any = await event.context.fetch( `models/c_invoice/${invoiceId}?$expand=C_Order_ID($select=C_Order_ID,amazon_order_id,C_OrderSource_ID,DocumentNo)`, 'GET', token, null ) if (!invoice) { const msg = `Invoice ${invoiceId} not found` logger.log(msg) errors.push(msg) continue } logger.log(`Invoice found: ${invoice.DocumentNo} (DocBaseType: ${invoice.DocBaseType?.id || invoice.DocBaseType})`) // Check if already uploaded if (invoice.isUploadToAmazon === true || invoice.isUploadToAmazon === 'Y') { const msg = `Invoice ${invoice.DocumentNo} already uploaded to Amazon - skipping` logger.log(msg) skipped.push(msg) continue } // Check if invoice has a linked order with an Amazon order ID const amazonOrderId = invoice.C_Order_ID?.amazon_order_id if (!amazonOrderId) { const msg = `Invoice ${invoice.DocumentNo}: No Amazon order ID found on linked order` logger.log(msg) errors.push(msg) continue } logger.log(`Amazon Order ID: ${amazonOrderId}`) // Fetch order source credentials const orderSourceId = invoice.C_Order_ID?.C_OrderSource_ID?.id if (!orderSourceId) { const msg = `Invoice ${invoice.DocumentNo}: No order source found on linked order` logger.log(msg) errors.push(msg) continue } let orderSource = orderSourceCache[orderSourceId] if (!orderSource) { logger.log(`Fetching order source: ${orderSourceId}`) orderSource = await event.context.fetch( `models/c_ordersource/${orderSourceId}`, 'GET', token, null ) orderSourceCache[orderSourceId] = orderSource } const marketplaceIdentifier = String(orderSource?.Marketplace?.identifier || '').toLowerCase() if (marketplaceIdentifier !== 'amazon') { const msg = `Invoice ${invoice.DocumentNo}: Order source Marketplace.identifier is "${orderSource?.Marketplace?.identifier || ''}", not "amazon" — upload skipped` logger.log(msg) skipped.push(msg) continue } if (!orderSource?.marketplace_key || !orderSource?.marketplace_secret || !orderSource?.marketplace_token) { const msg = `Invoice ${invoice.DocumentNo}: Order source missing Amazon SP-API credentials (marketplace_key, marketplace_secret, marketplace_token)` logger.log(msg) errors.push(msg) continue } const marketplaceId = resolveMarketplaceId(orderSource?.Marketplace?.identifier, orderSource?.Description) if (!marketplaceId) { const msg = `Invoice ${invoice.DocumentNo}: Could not resolve an Amazon marketplaceId from order source (identifier="${orderSource?.Marketplace?.identifier || ''}", Description="${orderSource?.Description || ''}")` logger.log(msg) errors.push(msg) continue } logger.log(`Resolved marketplaceId: ${marketplaceId}`) // Get or cache LWA access token let accessToken = accessTokenCache[orderSourceId] if (!accessToken) { accessToken = await getAmazonAccessToken( orderSource.marketplace_key, orderSource.marketplace_secret, orderSource.marketplace_token, logger ) accessTokenCache[orderSourceId] = accessToken } // Fetch invoice PDF from iDempiere logger.log(`Fetching PDF for invoice ${invoiceId}`) const pdfResponse: any = await event.context.fetch( `models/c_invoice/${invoiceId}/print?$report_type=PDF`, 'GET', token, null ) if (!pdfResponse?.reportFile) { const msg = `Invoice ${invoice.DocumentNo}: Could not generate PDF (reportFile missing)` logger.log(msg) errors.push(msg) continue } const pdfBuffer = Buffer.from(pdfResponse.reportFile, 'base64') logger.log(`PDF generated: ${pdfBuffer.length} bytes`) // If the partner is flagged for ZUGFeRD, merge the EN16931 XML into the PDF // before uploading. Fail-soft: returns the plain PDF when not flagged. const uploadBuffer = await maybeZugferdPdf(event, token, invoiceId, pdfBuffer) if (uploadBuffer !== pdfBuffer) { logger.log(`ZUGFeRD hybrid PDF generated: ${uploadBuffer.length} bytes`) } // Determine document type const docBaseType = invoice.DocBaseType?.id || invoice.DocBaseType?.identifier || invoice.DocBaseType || '' const documentType = (docBaseType === 'ARC') ? 'CreditNote' : 'Invoice' logger.log(`Document type: ${documentType} (DocBaseType: ${docBaseType})`) // Step 1: Create feed document const { feedDocumentId, url } = await createFeedDocument(accessToken, logger) // Step 2: Upload PDF await uploadPdfToAmazon(url, uploadBuffer, logger) // Step 3: Create feed const feedResult = await createFeed( accessToken, feedDocumentId, amazonOrderId, invoice.DocumentNo, documentType, marketplaceId, logger ) // Step 4: Mark invoice as uploaded in iDempiere try { // iDempiere's REST timestamp parser expects `yyyy-MM-dd'T'HH:mm:ss'Z'` // and rejects the millisecond fraction that toISOString() emits // (".300Z") with a 400 Bad Request. Strip the milliseconds. const uploadDate = new Date().toISOString().split('.')[0] + 'Z' logger.log(`Marking invoice ${invoiceId} as uploaded to Amazon`) await event.context.fetch( `models/c_invoice/${invoiceId}`, 'PUT', token, { isUploadToAmazon: true, uploadDateAmazon: uploadDate } ) logger.log(`Successfully marked invoice as uploaded`) } catch (markErr: any) { const detail = markErr?.data?.summary || markErr?.data?.message || markErr?.data?.title || markErr?.message || 'Unknown error' const msg = `Invoice ${invoice.DocumentNo}: uploaded to Amazon but could not mark as uploaded in iDempiere: ${detail}` logger.log(msg) errors.push(msg) } logger.log(`SUCCESS: Invoice ${invoice.DocumentNo} uploaded to Amazon (Feed ID: ${feedResult.feedId})`) uploadedCount++ // Rate limiting: Amazon allows 1 invoice per 3 seconds await new Promise(resolve => setTimeout(resolve, 3000)) } catch (err: any) { const msg = `Error processing invoice ${invoiceId}: ${err.message || 'Unknown error'}` logger.log(msg) errors.push(msg) } } logger.log(`=== SUMMARY: Uploaded ${uploadedCount} invoice(s), ${skipped.length} skipped, ${errors.length} error(s) ===`) // Output logs for debugging console.log('\n========== AMAZON INVOICE UPLOAD LOGS ==========') logger.getLogs().forEach(log => console.log(log)) console.log('=================================================\n') if (uploadedCount > 0) { data = { status: 200, message: `Successfully uploaded ${uploadedCount} invoice(s) to Amazon${skipped.length > 0 ? `, ${skipped.length} skipped (already uploaded)` : ''}`, uploadedCount, skippedCount: skipped.length, uploadDate: new Date().toISOString(), errors: errors.length > 0 ? errors : undefined, skipped: skipped.length > 0 ? skipped : undefined, logs: logger.getLogs() } } else if (skipped.length > 0 && errors.length === 0) { data = { status: 200, message: `All ${skipped.length} invoice(s) were already uploaded to Amazon`, uploadedCount: 0, skippedCount: skipped.length, skipped, logs: logger.getLogs() } } else { data = { status: 400, message: errors.length > 0 ? errors.join('; ') : 'No invoices were uploaded to Amazon', errors, skipped: skipped.length > 0 ? skipped : undefined, logs: logger.getLogs() } } return data } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { try { let authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) forceLogoutHelper(event, data) } } return data })