├── README.md ├── _config.yml ├── amazon-orders.lua ├── clean_webCache.sh ├── link_ext.sh ├── moneymoney_settings.png ├── new_master.sh ├── switch.sh ├── toogleCleanLocalStorage.sh ├── webCache_off.sh └── webCache_on.sh /README.md: -------------------------------------------------------------------------------- 1 | # Amazon Plugin for MoneyMoney 2 | ## Installation 3 | You can get the signed version from https://moneymoney-app.com/extensions/amazon-orders.lua. The unsigned [beta](https://raw.githubusercontent.com/Michael-Beutling/Amazon-MoneyMoney/beta/amazon-orders.lua) and [latest](https://raw.githubusercontent.com/Michael-Beutling/Amazon-MoneyMoney/master/amazon-orders.lua) versions are available from this repository. 4 | 5 | To activate the extension, copy the file into `~/Library/Containers/com.moneymoney-app.retail/Data/Library/Application\ Support/MoneyMoney/Extensions`. If you have cloned this repository you can use the `link_ext.sh` script in a shell. A restart is not required, it will load automatically. You can verify installation using *Window → Log*. 6 | 7 | **Look out:** MoneyMoney only runs unsigned plugins in the **beta version** and you need to **disable signature check** in the extentsion settings. 8 | 9 | ## Usage 10 | After installation go to *Add new account* → *Others* → *Amazon Orders*. There are a few different account types: 11 | * **Normal**: List your spendings like a normal bank account: Purchases are negative, refunds and bonuses are positive. 12 | * **Inverted**: Like a normal account, but inverted: Purchases are positive, refunds and bonuses are negative. 13 | * **Mix**: Shows both a positive and a negative booking for each purchase. The account total is always zero. 14 | * **Monthly**: Negative bookings for each purchase. A monthly positive counter booking to zero out the account. 15 | * **Yearly**: Negative bookings for each purchase. A yearly postitive counter booking to zero out the account. 16 | 17 | 18 | ## Config 19 | Some hard coded parameters can be overwritten in the MoneyMoney account settings: 20 |  21 | 22 | ## Performance 23 | The script caches some data, but the first time it scrapes your whole order history. In facts ~10 years of shopping with about 230 orders with 340 positions takes 12 minutes in the first run! The second run needs 2 minutes. After that all data is cached so a normal run needs 20-30 seconds. 24 | 25 | ## Reset cache to enforce full order history reload 26 | In MoneyMoney right-click the account, select settings, then notes. Add the attribute resetCache with any value, e.g. today's date. 27 | Going forward, whenever the attribute value gets changed, the cache is cleared and the whole account is reread. 28 | 29 | ## Blacklist invalid orders 30 | Orders that cause erros can be omitted via the blackListOrders attribute in the settings. Simply enter the order numbers separated by commas as a value. See screen shot above. 31 | 32 | ## Warranty 33 | Nope, no warranty! When the script orders 10 tons of dog food every day, it's your problem! 34 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /amazon-orders.lua: -------------------------------------------------------------------------------- 1 | -- Amazon Plugin for https://moneymoney-app.com 2 | -- 3 | -- Plugin Homepage https://github.com/Michael-Beutling/Amazon-MoneyMoney 4 | -- 5 | -- Copyright 2019-2023 Michael Beutling 6 | 7 | -- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files 8 | -- (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, 9 | -- merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 10 | -- furnished to do so, subject to the following conditions: 11 | 12 | -- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 13 | 14 | -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 15 | -- OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 16 | -- BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT 17 | -- OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | 19 | local connection=nil 20 | local secPassword 21 | local secUsername 22 | local captcha1run 23 | local mfa1run 24 | local aName 25 | local html 26 | local configDirty=false 27 | local webCache=false 28 | local webCacheFolder='webCache' 29 | local webCacheHit=false 30 | local webCacheState='start' 31 | local invalidPrice=1e99 32 | local invalidDate=1e99 33 | local invalidQty=1e99 34 | local cacheVersion=11 35 | local debugBuffer={context=''} 36 | local webCacheLastId=nil 37 | 38 | local config={ 39 | configOk=true, 40 | reallyLogout=true, 41 | cleanCookies=false, 42 | cleanOrdersCache=false, 43 | cleanFilterCache=false, 44 | cleanInvalidCache=false, 45 | noRefresh=false, 46 | debug=false, 47 | forceCaptcha=false, 48 | limitOrders=250, 49 | cookieLanguage='', 50 | rescanOrder='', 51 | blackListOrders='', 52 | } 53 | 54 | local const={ 55 | regexOrderCodeNew="([D%d]%d%d%-%d%d%d%d%d%d%d%-%d%d%d%d%d%d%d)", 56 | regexPriceOld="EUR%s+(%d+),(%d%d)", 57 | regexPriceNew="€(%d+),(%d%d)", 58 | str2date = { 59 | Januar=1, 60 | January=1, 61 | Februar=2, 62 | February=2, 63 | ["März"]=3, 64 | March=3, 65 | April=4, 66 | Mai=5, 67 | May=5, 68 | Juni=6, 69 | June=6, 70 | Juli=7, 71 | July=7, 72 | August=8, 73 | September=9, 74 | Oktober=10, 75 | October=10, 76 | November=11, 77 | Dezember=12, 78 | December=12 79 | }, 80 | domain='.amazon.de', 81 | services = {"Amazon Orders"}, 82 | description = "Give you an overview about your amazon orders.", 83 | contra="Amazon contra ", 84 | returnText="Returned item: ", 85 | returnTextContra="Amazon contra returned item: ", 86 | refundTransaction="Refund for order ", 87 | refundTransactionContra="Amazon contra refund for order ", 88 | fixEncoding='latin1', 89 | differenceText='Difference (shipping costs, coupon etc.)', 90 | xpathOrderHistoryLink='//a[@id="nav-orders" or contains(@href,"/order-history")]', 91 | xpathOrderMonthForm="//form[contains(@action,'order')][.//option]", 92 | xpathOrderMonthSelect='//select[@name="orderFilter" or @name="timeFilter"]', 93 | orderListLink='/gp/your-account/order-history?unifiedOrders=1', 94 | monthlyContra="monthy contra", 95 | yearlyContra="yearly contra", 96 | daysByMonth={31,28,31,30,31,30,31,31,30,31,30,31} 97 | } 98 | 99 | function mergeConfig(default,read) 100 | for k,v in pairs(default) do 101 | if type(v) == 'table' then 102 | if type(read[k]) ~= 'table' then 103 | read[k] = {} 104 | end 105 | mergeConfig(v,read[k]) 106 | else 107 | if type(read[k]) ~= 'nil'then 108 | if default[k]~=read[k] then 109 | default[k]=read[k] 110 | --print(k,'=',read[k]) 111 | end 112 | else 113 | configDirty=true 114 | end 115 | end 116 | end 117 | end 118 | 119 | 120 | local configFileName='amazon_orders.json' 121 | 122 | -- run every time which plug in is loaded 123 | local configFile=nil 124 | -- io=nil 125 | -- io.open=nil 126 | -- signed version has no io.open functions 127 | if io ~= nil and io.open ~= nil then 128 | configFile=io.open(configFileName,"rb") 129 | end 130 | 131 | if configFile~=nil then 132 | local configJson=configFile:read('*all') 133 | --print(configJson) 134 | local configTemp=JSON(configJson):dictionary() 135 | if configTemp['configOk'] then 136 | configDirty=false 137 | mergeConfig(config,configTemp) 138 | print('config read...') 139 | end 140 | io.close(configFile) 141 | else 142 | configDirty=true 143 | end 144 | 145 | 146 | if LocalStorage ~=nil then 147 | if LocalStorage.cacheVersion ~= cacheVersion then 148 | configDirty=true 149 | print("clean caches...") 150 | LocalStorage.OrderCache={} 151 | LocalStorage.orderFilterCache={} 152 | LocalStorage.cacheVersion = cacheVersion 153 | end 154 | 155 | if config.cleanOrdersCache and LocalStorage ~=nil then 156 | config.cleanOrdersCache=false 157 | configDirty=true 158 | print("clean orders cache...") 159 | LocalStorage.OrderCache={} 160 | end 161 | 162 | if config.cleanFilterCache then 163 | config.cleanFilterCache=false 164 | configDirty=true 165 | print("clean filter cache...") 166 | LocalStorage.orderFilterCache={} 167 | end 168 | 169 | if config.cleanInvalidCache then 170 | config.cleanInvalidCache=false 171 | configDirty=true 172 | print("clean invalid cache...") 173 | LocalStorage.invalidCache={} 174 | end 175 | 176 | if config.cleanCookies then 177 | config.cleanCookies=false 178 | configDirty=true 179 | print("clean cookies...") 180 | LocalStorage.cookies=nil 181 | end 182 | 183 | end 184 | 185 | if configDirty and io ~= nil and io.open ~= nil then 186 | print('write config...') 187 | configFile=io.open(configFileName,"wb") 188 | configFile:write(JSON():set(config):json()) 189 | io.close(configFile) 190 | end 191 | 192 | print(((io == nil or io.open == nil) and 'signed ' or '') .. const.services[1],"plugin loaded...") 193 | if config.debug then print('debugging...') end 194 | if debug ~= nil then 195 | print("lua debug is usable") 196 | end 197 | local baseurl='https://www'..const.domain 198 | 199 | WebBanking{version = 1.23, 200 | url = baseurl, 201 | services = const.services, 202 | description = const.description} 203 | 204 | function debugBuffer.tablePrint(tbl) 205 | local t={} 206 | for k,v in pairs(tbl) do 207 | if type(v)=='table' then 208 | table.insert(t,k.."(#table)={"..debugBuffer.tablePrint(v).."}") 209 | else 210 | table.insert(t,k.."#"..type(v).."='"..tostring(v).."'") 211 | end 212 | end 213 | return table.concat(t,",") 214 | end 215 | 216 | function debugBuffer.print(...) 217 | if debugBuffer.context == nil then 218 | debugBuffer.context='' 219 | end 220 | --local args={debugBuffer.getStack(),debugBuffer.context} 221 | local args={debugBuffer.context} 222 | for _,v in pairs({...}) do 223 | local n 224 | if type(v)=='table' then 225 | n=type(v).."='"..debugBuffer.tablePrint(v).."'" 226 | else 227 | n=type(v).."='"..tostring(v).."'" 228 | end 229 | table.insert(args,n) 230 | end 231 | table.insert(debugBuffer,table.concat(args," ")) 232 | end 233 | 234 | function debugBuffer.getStack(skip) 235 | local stack={} 236 | if skip== nil then 237 | skip=3 238 | end 239 | while debug.getinfo(skip) ~= nil do 240 | table.insert(stack,debug.getinfo(skip).name) 241 | skip=skip+1 242 | end 243 | 244 | return(table.concat(stack,"#")) 245 | end 246 | 247 | function debugBuffer.flush() 248 | if io ~= nil and config.debug then 249 | local debugFile=io.open("amazon-debug.log","a") 250 | if debugFile ~= nil then 251 | for i,v in ipairs(debugBuffer) do 252 | debugFile:write(v.."\n") 253 | debugBuffer[i]=nil 254 | end 255 | debugFile:close() 256 | end 257 | end 258 | for i,v in ipairs(debugBuffer) do 259 | print(v) 260 | debugBuffer[i]=nil 261 | end 262 | 263 | end 264 | 265 | function removeWebCacheLastItem() 266 | if webCache then 267 | os.remove(webCacheFolder..'/'..webCacheLastId..'.html') 268 | os.remove(webCacheFolder..'/'..webCacheLastId..'.json') 269 | print("remove",webCacheLastId,"from webCache") 270 | end 271 | end 272 | 273 | function connectShop(method, url, postContent, postContentType, headers) 274 | if method == nil then 275 | return nil 276 | end 277 | return HTML(connectShopRaw(method, url, postContent, postContentType, headers)) 278 | end 279 | 280 | function connectShopJson(method, url, postContent, postContentType, headers) 281 | if method == nil then 282 | return nil 283 | end 284 | headers={["X-Requested-With"]="XMLHttpRequest" } 285 | return JSON(connectShopRaw(method, url, postContent, postContentType, headers)):dictionary() 286 | end 287 | 288 | function connectShopRaw(method, url, postContent, postContentType, headers) 289 | -- postContentType=postContentType or "application/json" 290 | if headers == nil then 291 | headers={ 292 | --["DNT"]="1", 293 | --["Upgrade-Insecure-Requests"]="1", 294 | --["Connection"]="close", 295 | --["Accept"]="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 296 | } 297 | end 298 | 299 | if method == 'POST' then 300 | if config.debug then 301 | for i in string.gmatch(postContent, "([^&]+)") do 302 | print("post='"..i.."'") 303 | end 304 | end 305 | end 306 | 307 | if connection == nil then 308 | connection = Connection() 309 | --connection.useragent="Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:66.0) Gecko/20100101 Firefox/66.0" 310 | 311 | local status,err = pcall( function() 312 | for i in string.gmatch(LocalStorage.cookies, '([^; ]+)') do 313 | if i:sub(1, #'ap-fid=') ~= 'ap-fid=' and i:sub(-#'=deleted') ~= '=deleted' then 314 | -- print("keep cookie:"..i) 315 | connection:setCookie(i..'; Domain='..const.domain..'; Expires=Tue, 01-Jan-2036 08:00:01 GMT; Path=/') 316 | else 317 | -- print("suppress cockie:"..i) 318 | end 319 | end 320 | end) --pcall 321 | end 322 | 323 | local cached=false 324 | local content, charset, mimeType, filename, headers 325 | local writeCache=false 326 | if webCache then 327 | writeCache=true 328 | webCacheLastId=MM.md5(tostring(method)..tostring(url)..tostring(postContent)..tostring(postContentType)..tostring(headers)..webCacheState) 329 | local webFile=io.open(webCacheFolder..'/'..webCacheLastId..'.json','rb') 330 | if webFile then 331 | local metaJSON=webFile:read('*all') 332 | local meta=JSON(metaJSON):dictionary() 333 | webFile:close() 334 | webFile=io.open(webCacheFolder..'/'..webCacheLastId..'.html','rb') 335 | if webFile then 336 | content=webFile:read('*all') 337 | webFile:close() 338 | charset=meta['charset'] 339 | mimeType=meta['mimeType'] 340 | filename=meta['filename'] 341 | headers=meta['headers'] 342 | cached=true 343 | print("webCache id="..webCacheLastId.." read.") 344 | webCacheHit=true 345 | end 346 | writeCache=false 347 | end 348 | if not cached and webCacheHit then 349 | error('webCache error!') 350 | end 351 | 352 | end 353 | 354 | if not cached then 355 | -- issue #28 356 | if LocalStorage.patcher and LocalStorage.patcher.cookieLanguage then 357 | connection:setCookie('lc-acbde='..LocalStorage.patcher.cookieLanguage..'; Domain='..const.domain..'; Expires=Tue, 01-Jan-2036 08:00:01 GMT; Path=/') 358 | else 359 | connection:setCookie('lc-acbde=; Domain='..const.domain..'; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Path=/') 360 | end 361 | content, charset, mimeType, filename, headers = connection:request(method, url, postContent, postContentType, headers) 362 | if writeCache then 363 | local webFile=io.open(webCacheFolder..'/'..webCacheLastId..'.json',"wb") 364 | webFile:write(JSON():set({ 365 | charset=charset, 366 | mimeType=mimeType, 367 | filename=filename, 368 | headers=headers, 369 | request={ 370 | method=method, 371 | url=url, 372 | postContent=postContent, 373 | postContentType=postContentType, 374 | headers=headers, 375 | }, 376 | webCacheState=webCacheState, 377 | }):json()) 378 | webFile:close() 379 | webFile=io.open(webCacheFolder..'/'..webCacheLastId..'.html',"wb") 380 | webFile:write(content) 381 | webFile:close() 382 | print("webCache id="..webCacheLastId.." written.") 383 | end 384 | end 385 | 386 | if not cached and baseurl == connection:getBaseURL():lower():sub(1,#baseurl) then 387 | -- work around for deleted cookies, prevent captcha 388 | connection:setCookie('a-ogbcbff=; Domain='..const.domain..'; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Path=/') 389 | connection:setCookie('ap-fid=; Domain='..const.domain..'; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Path=/ap/; Secure') 390 | -- issue #28 391 | connection:setCookie('lc-acbde=; Domain='..const.domain..'; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Path=/') 392 | 393 | if config.debug then 394 | if LocalStorage.cookies~=connection:getCookies() then 395 | print("store cookies=",connection:getCookies()) 396 | end 397 | end 398 | 399 | for i in string.gmatch(connection:getCookies(), '([^; ]+)') do 400 | if i:sub(1, #'ap-fid=') == 'ap-fid=' or i:sub(-#'=deleted') == '=deleted' then 401 | error("unwanted cockie:"..i) 402 | end 403 | end 404 | LocalStorage.cookies=connection:getCookies() 405 | else 406 | -- if config.debug then print("skip cookie saving") end 407 | end 408 | 409 | return content,charset 410 | end 411 | 412 | local RegressionTest={} 413 | 414 | 415 | function RegressionTest.getKey(transaction) 416 | local sortedKeys={} 417 | for k,v in pairs(transaction) do 418 | table.insert(sortedKeys,k) 419 | end 420 | table.sort(sortedKeys) 421 | local key="" 422 | 423 | for _,k in ipairs(sortedKeys) do 424 | --key=key..k.."="..MM.base64(transaction[k].." ") 425 | key=key..k.."="..MM.toEncoding(const.fixEncoding,transaction[k]).." " 426 | end 427 | return key 428 | end 429 | 430 | function RegressionTest.makeKeys(transactions) 431 | local keys={} 432 | for _,transaction in pairs(transactions) do 433 | 434 | keys[RegressionTest.getKey(transaction)]=true 435 | 436 | end 437 | 438 | return keys 439 | end 440 | 441 | 442 | function RegressionTest.compareTransactions(now,master,differences,text) 443 | local keys=RegressionTest.makeKeys(now) 444 | for _,transaction in pairs(master) do 445 | local key=RegressionTest.getKey(transaction) 446 | 447 | if keys[key] ~= true then 448 | local diff={} 449 | for k,v in pairs(transaction) do 450 | diff[k]=v 451 | end 452 | diff.name=diff.name.." "..text 453 | diff.amount=tonumber(diff.amount) 454 | diff.purpose=diff.purpose.."\n"..MM.base64(key) 455 | table.insert(differences,diff) 456 | end 457 | end 458 | 459 | return differences 460 | end 461 | 462 | function RegressionTest.run(transactions,regTestPre) 463 | if io ~= nil then 464 | local transFile=io.open(regTestPre.."_transactions_master.json",'rb') 465 | if transFile ~= nil then 466 | 467 | debugBuffer.print("run regression test") 468 | 469 | local master=JSON(transFile:read('*all')):dictionary() 470 | transFile.close() 471 | 472 | for _,v in pairs(transactions) do 473 | v.amount=tostring(v.amount) 474 | end 475 | local transFile=io.open(regTestPre.."_transactions.json","wb") 476 | transFile:write(JSON():set(transactions):json()) 477 | transFile.close() 478 | 479 | local differences={} 480 | 481 | RegressionTest.compareTransactions(transactions,master,differences,"master") 482 | RegressionTest.compareTransactions(master,transactions,differences,"now") 483 | 484 | local count = #transactions 485 | local i 486 | for i=0, count do transactions[i]=nil end 487 | for _,v in pairs(differences) do 488 | table.insert(transactions,v) 489 | end 490 | 491 | debugBuffer.print("regression test finish") 492 | table.insert(transactions,{ 493 | name="regression test finish", 494 | amount = #differences, 495 | bookingDate = os.time(), 496 | purpose = 'run '..LocalStorage.loginCounter, 497 | booked = false, 498 | accountNumber='accountNumber', 499 | bankCode='bankCode', 500 | bookingText='bookingText', 501 | endToEndReference='endToEndReference', 502 | mandateReference='mandateReference', 503 | creditorId='creditorId', 504 | returnReason='returnReason', 505 | --comment='comment\ncomment\n', 506 | --category="test" 507 | }) 508 | end 509 | end 510 | debugBuffer.print(transactions) 511 | debugBuffer.flush() 512 | end 513 | 514 | function connectShopWithCheck(method, url, postContent, postContentType, headers) 515 | if method == nil then 516 | return nil 517 | end 518 | local html=HTML(connectShopRaw(method, url, postContent, postContentType, headers)) 519 | local xpform='//form[@name="signIn"]' 520 | if html:xpath(xpform):attr("name") ~= '' then 521 | removeWebCacheLastItem() 522 | print("Forced log out detect, enter username/password") 523 | html:xpath('//*[@name="email"]'):attr("value", secUsername) 524 | html:xpath('//*[@name="password"]'):attr("value",secPassword) 525 | html= connectShop(html:xpath(xpform):submit()) 526 | end 527 | return html 528 | end 529 | 530 | function getDate(text) 531 | if type(text)~='string' then 532 | return invalidDate 533 | end 534 | local day,month,year=string.match(text,"(%d+)%.%s+([%S]+)%s+(%d+)") 535 | if day == nil then 536 | day,month,year=string.match(text,"(%d+)%s+([%S]+)%s+(%d+)") 537 | end 538 | local month=const.str2date[month] 539 | if month ~= nil then 540 | return os.time({year=year,month=month,day=day}) 541 | end 542 | --error(text) 543 | return invalidDate -- error value 544 | end 545 | 546 | function getPrice(text) 547 | if type(text)~='string' then 548 | return invalidPrice 549 | end 550 | local amountHigh,amountLow=string.match(text:gsub("%.",""),const.regexPriceNew) 551 | if amountHigh == nil or amountLow == nil then 552 | amountHigh,amountLow=string.match(text:gsub("%.",""),const.regexPriceOld) 553 | end 554 | --debugBuffer.print(text,amountHigh,amountLow) 555 | if amountHigh == nil or amountLow == nil then 556 | return invalidPrice 557 | end 558 | return amountHigh*100+amountLow 559 | end 560 | 561 | function getQty(text) 562 | if type(text)~='string' then 563 | return invalidQty 564 | end 565 | local qty=tonumber(text) 566 | if qty>0 then 567 | return qty 568 | end 569 | return invalidQty 570 | end 571 | 572 | function getQtyFromElement(element) 573 | local qty=1 574 | if nodeExists(element,'.//span[contains(@class,"item-view-qty")]') then 575 | qty=getQty(element:xpath('.//span[contains(@class,"item-view-qty")]'):text()) 576 | end 577 | return qty 578 | end 579 | 580 | function getOrderCode(text) 581 | if type(text)~='string' then 582 | return nil 583 | end 584 | local orderCode=string.match(text,const.regexOrderCodeNew) 585 | return orderCode 586 | end 587 | 588 | function nodeExists(element,xpath) 589 | return element:xpath(xpath)[1] ~= nil 590 | end 591 | 592 | function getLastElementText(html,...) 593 | local elements=html:xpath(table.concat({...})) 594 | if elements:length() == 0 then 595 | return '' 596 | end 597 | return elements:get(elements:length()):text() 598 | end 599 | 600 | function getOrderInfosFromSummaryHeader(orderInfo,order) 601 | if orderInfo:text() == "" then 602 | return false 603 | end 604 | 605 | local headData={} 606 | 607 | orderInfo:xpath('.//span[contains(@class,"a-color-secondary") and contains(@class,"value")]'):each(function(index,element) 608 | headData[index]=element:text() 609 | end) 610 | 611 | if #headData == 3 then 612 | -- customer account 613 | order.orderCode=getOrderCode(headData[3]) 614 | debugBuffer.context=order.orderCode 615 | order.bookingDate=getDate(headData[1]) 616 | order.orderTotal=getPrice(headData[2]) 617 | elseif #headData == 4 then 618 | -- business account 619 | order.orderCode=getOrderCode(headData[4]) 620 | debugBuffer.context=order.orderCode 621 | order.bookingDate=getDate(headData[1]) 622 | order.accountNumber=headData[2] 623 | order.orderTotal=getPrice(headData[3]) 624 | elseif #headData == 5 then 625 | -- business account 626 | order.orderCode=getOrderCode(headData[5]) 627 | debugBuffer.context=order.orderCode 628 | order.bookingDate=getDate(headData[1]) 629 | order.accountNumber=headData[2] 630 | order.bookingText=headData[4] 631 | order.orderTotal=getPrice(headData[3]) 632 | else 633 | debugBuffer.print("unkown elements",table.concat(headData,"#")) 634 | return false 635 | end 636 | 637 | -- only business accounts 638 | local endToEndReference=orderInfo:xpath('.//div[contains(@class,"placed-by")]//span[contains(@class,"trigger-text")]'):text() 639 | if endToEndReference ~= '' then 640 | order.endToEndReference=endToEndReference 641 | end 642 | 643 | if order.bookingDate == invalidDate then 644 | debugBuffer.print("getOrderInfosFromSummaryHeader invalidDate") 645 | order.orderCode=nil 646 | end 647 | 648 | if order.orderTotal == invalidPrice then 649 | debugBuffer.print("getOrderInfosFromSummaryHeader invalidPrice") 650 | order.orderCode=nil 651 | end 652 | 653 | order.detailsUrl=orderInfo:xpath('.//a[contains(@class,"a-link-normal") and contains(@href,"/order-details/")]'):attr('href') 654 | if order.detailsUrl == "" then 655 | order.digitalUrl=orderInfo:xpath('.//a[contains(@class,"a-link-normal") and contains(@href,"/digital/")]'):attr('href') 656 | if order.digitalUrl == "" then 657 | debugBuffer.print("getOrderInfosFromSummaryHeader nodetails") 658 | order.orderCode=nil 659 | end 660 | end 661 | 662 | return order.orderCode ~= nil 663 | end 664 | 665 | function isShipmentShorted(shipment) 666 | return shipment:xpath('.//a[contains(@href,"/order-details/")]'):length() ~= 0 667 | end 668 | 669 | --- @type orderPosition 670 | -- @field purpose 671 | -- @field amount 672 | -- @field qty 673 | 674 | --- @type order 675 | -- @field #string orderCode 676 | -- @field #number totalSum 677 | -- @field #number orderTotal total from header 678 | -- @field #number refund sum of refund from header 679 | -- @field #number bookingDate date of order 680 | -- @field #string detailsUrl 681 | -- @field #string digitalUrl 682 | -- @field #list<#orderPosition> orderPositions 683 | -- @field #boolean invalidArticles 684 | -- @field #number detailsDate 685 | -- @field #string accountNumber 686 | -- @field #string endToEndReference 687 | 688 | --- @type totals 689 | -- @field #number orderTotal Sum of order showed by Amazon 690 | -- @field #number refund amount of refund showed by Amazon 691 | 692 | --- @function getTotalsFromDetails 693 | -- @return #totals 694 | -- 695 | 696 | 697 | 698 | function getTotalsFromDetails(orderDetails) 699 | local totals={} --#totals 700 | 701 | local xPathPrefix=('//div[contains(@id,"od-subtotals")]//div[contains(@class,"a-span-last")]//') 702 | totals.orderTotal=getPrice(getLastElementText(orderDetails,xPathPrefix,'span[contains(@class,"a-color-base") and contains(@class,"a-text-bold")]')) 703 | totals.refund=getPrice(getLastElementText(orderDetails,xPathPrefix,'span[contains(@class,"a-color-success") and contains(@class,"a-text-bold")]')) 704 | if totals.refund ==invalidPrice then 705 | totals.refund=0 706 | end 707 | return totals 708 | 709 | end 710 | 711 | --- @function getArticleFromShipment 712 | -- @param #string shipment 713 | -- @param #order order 714 | -- @param #boolean doInsert 715 | -- @return 716 | function getArticleFromShipment(shipment,order,doInsert) 717 | doInsert=doInsert ~= false 718 | 719 | local refund=invalidPrice 720 | local refundText=shipment:xpath('.//div[contains(@class,"actions")]'):text() 721 | if refundText ~=""then 722 | refund=getPrice(refundText) 723 | --debugBuffer.print("action",order.orderCode,doInsert,refund) 724 | end 725 | 726 | shipment:xpath('.//div[contains(@class,"a-fixed-left-grid-inner")]'):each(function(index,article) 727 | local purpose 728 | local amount=invalidPrice 729 | local qty=getQtyFromElement(article) 730 | article:xpath('.//div[contains(@class,"a-row")]'):each(function(index,row) 731 | if purpose==nil then 732 | purpose=row:text() 733 | else 734 | local price=getPrice(row:text()) 735 | if price~=invalidPrice and amount == invalidPrice then 736 | amount=price 737 | end 738 | end 739 | end) -- row 740 | if order.digitalUrl ~= nil then 741 | amount=order.orderTotal 742 | --debugBuffer.print(amount,purpose,qty) 743 | end 744 | if purpose~= nil and amount ~=invalidPrice and qty~= invalidQty then 745 | if doInsert then 746 | table.insert(order.orderPositions,{purpose=purpose,amount=amount,qty=qty}) 747 | order.orderSum=order.orderSum+amount*qty 748 | end 749 | if refund~=invalidPrice then 750 | order.orderPositions[#order.orderPositions].refund=refund 751 | refund=invalidPrice 752 | --debugBuffer.print("refunded",order) 753 | end 754 | else 755 | order.invalidArticles=true 756 | --debugBuffer.print("invalid article",order.orderCode,amount,qty) 757 | end 758 | end) -- article 759 | end 760 | 761 | --- @function makeBranch 762 | -- @param #map tree 763 | -- @param #list branch 764 | -- @return #map 765 | 766 | 767 | function makeBranch(tree,branch) 768 | local temp=tree 769 | for _,v in ipairs(branch) do 770 | if temp[v] == nil then 771 | temp[v]={} 772 | end 773 | temp=temp[v] 774 | end 775 | return temp 776 | end 777 | 778 | --- @type returned 779 | -- @field #number amount 780 | -- @number #number bookingDate 781 | 782 | --- @function getReturnsFromDetails 783 | -- @param #table orderDetails 784 | -- @param #order order 785 | -- @return 786 | 787 | function getReturnsFromDetails(orderDetails,order) 788 | orderDetails:xpath('//div[contains(@id,"od-returns-panel")]//div[contains(@class,"a-box-inner")]'):each(function(index,returnedShipments) 789 | -- debugBuffer.print(order.orderCode) 790 | local bookingDate=getDate(returnedShipments:xpath('.//div[@class="a-row a-spacing-base"]'):text()) 791 | 792 | if bookingDate ~= invalidDate then 793 | returnedShipments:xpath('.//div[contains(@class,"a-row")and contains(@class,"a-spacing-mini")]'):each(function(index,returnedItems) 794 | local purpose 795 | local amount=invalidPrice 796 | returnedItems:xpath('.//div[contains(@class,"a-row")]'):each(function(index,row) 797 | if purpose==nil then 798 | purpose=row:text() 799 | else 800 | local price=getPrice(row:text()) 801 | if price~=invalidPrice then 802 | amount=price 803 | end 804 | end 805 | end) -- row 806 | if amount ~=invalidPrice and bookingDate ~=invalidDate then 807 | makeBranch(order,{'returns',bookingDate,amount,purpose}) 808 | -- debugBuffer.print(order.returns) 809 | end 810 | end) 811 | end 812 | end) 813 | return 814 | end 815 | 816 | --- @function getRefundTransActions 817 | -- @param #table orderDetails 818 | -- @param #order order 819 | -- @return 820 | -- 821 | function getRefundTransActions(orderDetails,order) 822 | orderDetails:xpath('.//div[contains(@class,"a-box") and contains(@class,"a-last")]//div[contains(@class,"a-row") and contains(@class,"a-color-success")]'):each(function(index,transaction) 823 | local bookingDate=getDate(transaction:text()) 824 | local amount=getPrice(transaction:text()) 825 | if bookingDate ~= invalidDate and amount ~= invalidPrice then 826 | makeBranch(order,{'refundTransactions',bookingDate,amount}) 827 | end 828 | end) 829 | return 830 | end 831 | 832 | 833 | --- @function getOrderaddress 834 | -- @param #table html 835 | -- @param #order order 836 | -- @return 837 | -- 838 | 839 | function getOrderaddress(orderDetails,order) 840 | if order.endToEndReference == nil then 841 | local name=orderDetails:xpath('//div[contains(@class,"od-shipping-address-container")]//div[@class="a-row"]'):text() 842 | local address=orderDetails:xpath('//div[contains(@class,"od-shipping-address-container")]//div[@class="displayAddressDiv"]'):text() 843 | 844 | if name ~='' and address ~= '' then 845 | name=name.." "..address 846 | elseif name == '' then 847 | name=address 848 | end 849 | 850 | if name ~= '' then 851 | order.endToEndReference=name 852 | end 853 | end 854 | end 855 | 856 | --- @function getOrderDetails 857 | -- @param #order order 858 | -- @return 859 | -- 860 | function getOrderDetails(order) 861 | debugBuffer.context=order.orderCode 862 | if order.detailsUrl ~= "" then 863 | --debugBuffer.print("getOrderDetails") 864 | local html=connectShopWithCheck("GET",order.detailsUrl) 865 | local orderDetails=html:xpath('//div[contains(@id,"orderDetails")]') 866 | if orderDetails:text() ~="" then 867 | local totals=getTotalsFromDetails(html) 868 | --debugBuffer.print("total error",order.orderCode,"order",order.orderTotal , "totals",totals.orderTotal) 869 | local doInsert=#order.orderPositions == 0 870 | if doInsert then 871 | order.orderSum=0 872 | end 873 | local shipments=orderDetails:xpath('.//div[contains(concat(" ", normalize-space(@class), " "), " a-box shipment ")]') 874 | if shipments:text()=='' then 875 | shipments=orderDetails:xpath('./div[contains(concat(" ", normalize-space(@class), " "), " a-box ")]') 876 | end 877 | shipments:each( function(index,shipment) 878 | getArticleFromShipment(shipment,order,doInsert) 879 | end) 880 | getReturnsFromDetails(orderDetails,order) 881 | getRefundTransActions(orderDetails,order) 882 | getOrderaddress(orderDetails,order) 883 | order.detailsDate=os.time()+math.floor((math.random()*90+90)*24*60*60) -- distribute rescans randomly in future 884 | else 885 | debugBuffer.print("getOrderDetails no details",order.orderCode) 886 | end 887 | else 888 | -- no handling for digital orders 889 | order.detailsDate=os.time()+math.floor((math.random()*90+90)*24*60*60) -- distribute rescans in future 890 | end 891 | debugBuffer.context='' 892 | end 893 | 894 | function getOrdersFromSummary(html) 895 | local orders={} 896 | html:xpath('//div[contains(@id,"ordersContainer") or contains(@class,"orders-content-container")]//div[contains(@class," order") and .//div[contains(@class," order-info")]]'):each(function(index,orderBox) 897 | local orderInfo=orderBox:xpath('.//div[contains(@class,"order-info")]') 898 | local order={orderPositions={},orderSum=0,refund=0,detailsDate=2} -- #order 899 | if getOrderInfosFromSummaryHeader(orderInfo,order) then 900 | orderBox:xpath('.//div[not(contains(@class,"order-info"))]//div[contains(@class,"a-box-inner")]'):each(function(index,shipment) 901 | if isShipmentShorted(shipment) then 902 | order.detailsDate=0 903 | --debugBuffer.print("shorted",order.orderCode) 904 | else 905 | getArticleFromShipment(shipment,order) 906 | end 907 | end) -- shipment 908 | if order.invalidArticles ~= nil then 909 | order.orderPositions={} 910 | order.invalidArticles=nil 911 | end 912 | orders[order.orderCode]=order 913 | end 914 | debugBuffer.flush() 915 | debugBuffer.context='' 916 | end) -- orderbox 917 | return orders 918 | end 919 | 920 | function getMessageListURL(ajaxToken,page,pageToken) 921 | local url='/gp/message/ajax/message-list.html?' 922 | local fields={ 923 | messageType='all', 924 | startDateTime=1000, 925 | endDateTime=3167942400000, 926 | pageSize=10, 927 | pageNum=page, 928 | sourcePage='inbox', 929 | isMobile=0, 930 | pageToken=pageToken, 931 | token=ajaxToken, 932 | stringDebug='', 933 | isDebug='' 934 | } 935 | if ajaxToken == nil then 936 | -- https://www.amazon.de/gp/msg/cntr/message-list/?messageType=all&startDateTime=NaN&endDateTime=NaN&pageSize=10&pageNum=1&sourcePage=inbox&isMobile=0&token=stateData.token&stringDebug=&isDebug= 937 | url='/gp/msg/cntr/message-list/?' 938 | fields.startDateTime='NaN' 939 | fields.endDateTime='NaN' 940 | fields.token='stateData.token' 941 | end 942 | local t={} 943 | for k,v in pairs(fields) do 944 | if v ~= nil then 945 | table.insert(t,k..'='..MM.urlencode(v)) 946 | end 947 | end 948 | return url..table.concat(t,"&") 949 | end 950 | 951 | function getMessageURL(ajaxToken,messageId,threadId,messageDateTime) 952 | --https://www.amazon.de/gp/msg/cntr/message-content/?messageId=urn%3Artn%3Amsg%&threadId=&messageType=all&sourcePage=inbox&messageDateTime=16667&isMobile=0&token=stateData.token&stringDebug=&isDebug= 953 | local url 954 | local fields={ 955 | messageId=messageId, 956 | threadId=threadId, 957 | messageType='all', 958 | sourcePage='inbox', 959 | messageDateTime=messageDateTime, 960 | isMobile=0, 961 | token=ajaxToken, 962 | stringDebug='', 963 | isDebug='' 964 | } 965 | if ajaxToken == nil then 966 | url='/gp/msg/cntr/message-content/?' 967 | fields.token='stateData.token' 968 | else 969 | url='/gp/message/ajax/message-content.html?' 970 | end 971 | local t={} 972 | for k,v in pairs(fields) do 973 | if v ~= nil then 974 | table.insert(t,k..'='..MM.urlencode(v)) 975 | end 976 | end 977 | return url..table.concat(t,"&") 978 | end 979 | 980 | 981 | function getMessageList(since) 982 | since=since*1000 -- in milliseconds 983 | local orderIds={} 984 | local html=connectShop("GET","/gp/message") 985 | local ajaxToken=html:xpath('//script[contains(@type,"a-state")]'):text() 986 | ajaxToken=string.match(ajaxToken,'{"token":"([A-Za-z0-9]+)"}') 987 | print("ajaxToken",ajaxToken) 988 | if ajaxToken ~= "" then 989 | local page=1 990 | local messages={} 991 | local nextPageToken 992 | repeat 993 | MM.printStatus("Get page",page,"from Amazon message center.") 994 | local html 995 | local noNextPage=true 996 | --debugBuffer.print(page,json) 997 | if ajaxToken ~= nil then 998 | local json=connectShopJson("GET",getMessageListURL(ajaxToken,page,nextPageToken)) 999 | if json.html ~= nil then 1000 | html=HTML("
"..json['html'].."") 1001 | json.html = nil 1002 | end 1003 | if json.nextPageToken~= nil then 1004 | nextPageToken=json.nextPageToken 1005 | noNextPage=true 1006 | end 1007 | else 1008 | html=connectShop("GET",getMessageListURL(ajaxToken,page,nextPageToken)) 1009 | 1010 | nextPageToken=html:xpath("//div[@id='nextPageTokenValue']"):attr('data-val') 1011 | if nextPageToken ~= '' then 1012 | noNextPage=false 1013 | end 1014 | end 1015 | --debugBuffer.flush() 1016 | 1017 | local newMessages=false 1018 | html:xpath('//td'):each(function(index,td) 1019 | local message={} 1020 | for _,k in pairs({'messageSentTime','message-sent-time-in-ms','messageId','message-id','threadId','thread-id'}) do 1021 | message[k]=td:attr(k:lower()) 1022 | end 1023 | if message['message-sent-time-in-ms'] ~= '' then 1024 | message.messageSentTime=message['message-sent-time-in-ms'] 1025 | message.threadId=message['threadId'] 1026 | message.messageId=message['message-id'] 1027 | end 1028 | if tonumber(message.messageSentTime) > since then 1029 | messages[message.messageId]=message 1030 | newMessages=true 1031 | end 1032 | debugBuffer.print(message) 1033 | end) 1034 | --debugBuffer.print(page,json) 1035 | if not newMessages then 1036 | noNextPage=true 1037 | end 1038 | page=page+1 1039 | until noNextPage 1040 | local numAll=0 1041 | local num=0 1042 | for _,v in pairs(messages) do 1043 | numAll=numAll+1 1044 | end 1045 | for _,v in pairs(messages) do 1046 | num=num+1 1047 | MM.printStatus("Get Amazon message",num,"of",numAll) 1048 | local html 1049 | if ajaxToken ~= nil then 1050 | local json=connectShopJson("GET",getMessageURL(ajaxToken,v.messageId,v.threadId,v.messageSentTime)) 1051 | if json.html ~= nil then 1052 | html=HTML(""..json['html'].."") 1053 | else 1054 | html='' 1055 | end 1056 | else 1057 | html=connectShop("GET",getMessageURL(ajaxToken,v.messageId,v.threadId,v.messageSentTime)) 1058 | end 1059 | for orderId in html:html():gmatch(const.regexOrderCodeNew) do 1060 | orderIds[orderId]=tonumber(v.messageSentTime)/1000 -- in milliseconds 1061 | end 1062 | end 1063 | end 1064 | local numOrders=0 1065 | for k,v in pairs(orderIds) do 1066 | numOrders=numOrders+1 1067 | end 1068 | print(numOrders,"orders from messages") 1069 | --debugBuffer.print(orderIds) 1070 | --debugBuffer.flush() 1071 | return orderIds 1072 | end 1073 | 1074 | function getLastDayOfPeriod(period) 1075 | local year=string.match(period,"(%d%d%d%d)") 1076 | local month=string.match(period,"-(%d%d)") 1077 | --debugBuffer.print("getLastDayOfPeriod",period,year,month) 1078 | if month == nil then 1079 | month="12" 1080 | end 1081 | year=tonumber(year) 1082 | month=tonumber(month) 1083 | local day=const.daysByMonth[month] 1084 | if month == 2 and (year%4) == 0 and ((year%400)==0 or (year%100)~=0) then 1085 | day=29 1086 | end 1087 | return os.time{year=year,month=month,day=day} 1088 | end 1089 | 1090 | function SupportsBank (protocol, bankCode) 1091 | return protocol == ProtocolWebBanking and "Amazon Orders" == bankCode:sub(1,#"Amazon Orders") 1092 | end 1093 | 1094 | function enterOrderList () 1095 | --print("enterOrderList") 1096 | html= connectShop(html:xpath(const.xpathOrderHistoryLink):click()) 1097 | if html == nil then 1098 | print("work-around, see issue #21") 1099 | html=connectShop("GET",baseurl..const.orderListLink) 1100 | end 1101 | end 1102 | 1103 | function endsWith(string,ending) 1104 | return string:sub(-#ending) == ending 1105 | end 1106 | 1107 | function InitializeSession2 (protocol, bankCode, step, credentials, interactive) 1108 | -- Login. 1109 | if type(LocalStorage.patcher) == 'table' then 1110 | for k,v in pairs(LocalStorage.patcher) do 1111 | print("attribut",k,v) 1112 | if type(config[k]) == 'boolean' then 1113 | if v == 'true' then 1114 | print("set config",k,"= true") 1115 | config[k]=true 1116 | else 1117 | print("set config",k,"= false") 1118 | config[k]=false 1119 | end 1120 | end 1121 | if type(const[k]) == 'string' then 1122 | print("const k=",v) 1123 | const[k]=v 1124 | end 1125 | end 1126 | end 1127 | if step==1 then 1128 | if LocalStorage.getOrders == nil then 1129 | LocalStorage.getOrders={} 1130 | end 1131 | secUsername=credentials[1] 1132 | secPassword=credentials[2] 1133 | captcha1run=true 1134 | mfa1run=true 1135 | aName=nil 1136 | 1137 | if LocalStorage.loginCounter == nil then 1138 | LocalStorage.loginCounter=0 1139 | end 1140 | LocalStorage.loginCounter=LocalStorage.loginCounter+1 1141 | print("run=",LocalStorage.loginCounter) 1142 | 1143 | if config.debug then 1144 | webCache=os.rename(webCacheFolder,webCacheFolder) and true or false 1145 | if webCache then 1146 | print("webcache on") 1147 | config.limitOrders=1e99 1148 | local temp=webCacheFolder.."/cleanLocalStorage" 1149 | local cleanLocalStorage=os.rename(temp,temp) and true or false 1150 | if cleanLocalStorage then 1151 | print("clean LocalStorage") 1152 | LocalStorage.OrderCache={} 1153 | LocalStorage.orderFilterCache={} 1154 | LocalStorage.newestMessage=0 1155 | LocalStorage.balancesByPeriod={} 1156 | end 1157 | end 1158 | end 1159 | html = connectShop("GET",baseurl) 1160 | enterOrderList() 1161 | end 1162 | 1163 | local leaveLoginLoop 1164 | local loginLoops=1 1165 | repeat 1166 | leaveLoginLoop=true 1167 | webCacheState="login"..loginLoops 1168 | print("login "..loginLoops..". try") 1169 | 1170 | -- $x('//div[@id="auth-error-message-box"]') 1171 | local authError=html:xpath('//div[@id="auth-error-message-box"]'):text() 1172 | 1173 | if authError ~= '' then 1174 | MM.printStatus(authError) 1175 | print('login failed, clean cookies text') 1176 | LocalStorage.cookies=nil 1177 | return LoginFailed 1178 | end 1179 | 1180 | 1181 | 1182 | -- authlink 1183 | -- 1184 | -- $x('//form[@id="pollingForm"]') 1185 | -- $x('//input[@name="transactionApprovalStatus"]') 1186 | -- 1187 | -- 1188 | -- 1189 | 1190 | local authLink=html:xpath('//form[@id="pollingForm"]') 1191 | if authLink:attr('id') ~='' then 1192 | print("auth link sended") 1193 | local waitUntil=os.time()+300 1194 | local poll 1195 | repeat 1196 | MM.printStatus("waiting for auth confirmation, "..math.floor(waitUntil-os.time()).." seconds left") 1197 | MM.sleep(3) 1198 | poll=connectShop(authLink:submit()):xpath('//input[@name="transactionApprovalStatus"]'):attr('value') 1199 | print("poll="..poll) 1200 | until( poll == 'TransactionCompleted' or waitUntil