diff --git a/beancount_import/amount_parsing.py b/beancount_import/amount_parsing.py index 79e25c2c..213d8539 100644 --- a/beancount_import/amount_parsing.py +++ b/beancount_import/amount_parsing.py @@ -30,12 +30,18 @@ def parse_amount(x, assumed_currency=None): if not x: return None sign, amount_str = parse_possible_negative(x) - m = re.fullmatch(r'(?:[(][^)]+[)])?\s*([\$€£])?((?:[0-9](?:,?[0-9])*|(?=\.))(?:\.[0-9]+)?)(?:\s+([A-Z]{3}))?', amount_str) + m = re.fullmatch(r'(?:[(][^)]+[)])?\s*([\$€£]|[A-Z]{3})?\s*((?:[0-9](?:,?[0-9])*|(?=\.))(?:\.[0-9]+)?)(?:\s+([A-Z]{3}))?', amount_str) if m is None: raise ValueError('Failed to parse amount from %r' % amount_str) if m.group(1): - currency = {'$': 'USD', '€': 'EUR', '£': 'GBP'}[m.group(1)] + # unit before amount + if len(m.group(1)) == 3: + # 'EUR' or 'USD' + currency = m.group(1) + else: + currency = {'$': 'USD', '€': 'EUR', '£': 'GBP'}[m.group(1)] elif m.group(3): + # unit after amount currency = m.group(3) elif assumed_currency is not None: currency = assumed_currency diff --git a/beancount_import/source/amazon.py b/beancount_import/source/amazon.py index 484ae779..fc89f3d8 100644 --- a/beancount_import/source/amazon.py +++ b/beancount_import/source/amazon.py @@ -41,6 +41,7 @@ 'Gift Card Amount': 'Assets:Gift-Cards:Amazon', 'Rewards Points': 'Income:Amazon:Cashback', }, + locale='en_US' # optional, defaults to 'en_US' ) The `amazon_account` key must be specified, and should be set to the email @@ -54,6 +55,9 @@ specify these keys in the configuration, the generic automatic account prediction will likely handle them. +The `locale` sets country/language specific settings. +Currently, `en_US` and `de_DE` are available. + Specifying credit cards ======================= @@ -260,10 +264,11 @@ """ import collections -from typing import Dict, List, Tuple, Optional +from typing import Dict, List, Tuple, Optional, Union import os import sys import pickle +import logging from beancount.core.data import Transaction, Posting, Balance, Commodity, Price, EMPTY_SET, Directive from beancount.core.amount import Amount @@ -271,7 +276,7 @@ from beancount.core.number import ZERO, ONE import beancount.core.amount -from .amazon_invoice import parse_invoice, DigitalItem, Order +from .amazon_invoice import LOCALES, parse_invoice, DigitalItem, Order from ..matching import FIXME_ACCOUNT, SimpleInventory from ..posting_date import POSTING_DATE_KEY, POSTING_TRANSACTION_DATE_KEY @@ -280,6 +285,8 @@ import datetime +logger = logging.getLogger('amazon') + ITEM_DESCRIPTION_KEY = 'amazon_item_description' ITEM_URL_KEY = 'amazon_item_url' ITEM_BY_KEY = 'amazon_item_by' @@ -296,10 +303,11 @@ def make_amazon_transaction( - invoice, + invoice: Order, posttax_adjustment_accounts, credit_card_accounts, amazon_account: str, + payee='Amazon.com' ): txn = Transaction( date=invoice.order_date, @@ -307,7 +315,7 @@ def make_amazon_transaction( (ORDER_ID_KEY, invoice.order_id), (AMAZON_ACCOUNT_KEY, amazon_account), ]), - payee='Amazon.com', + payee=payee, narration='Order', flag=FLAG_OKAY, tags=EMPTY_SET, @@ -322,7 +330,7 @@ def make_amazon_transaction( meta = collections.OrderedDict([ (ITEM_DESCRIPTION_KEY, item.description), (SELLER_KEY, item.sold_by), - ]) + ]) # type: Dict[str, Optional[Union[str, datetime.date]]] if isinstance(item, DigitalItem): if item.url: meta[ITEM_URL_KEY] = item.url @@ -539,6 +547,7 @@ def __init__(self, posttax_adjustment_accounts: Dict[str, str] = {}, pickle_dir: str = None, earliest_date: datetime.date = None, + locale='en_US', **kwargs) -> None: super().__init__(**kwargs) self.directory = directory @@ -551,6 +560,7 @@ def __init__(self, self.pickler = AmazonPickler(pickle_dir) self.earliest_date = earliest_date + self.locale = LOCALES[locale] self.invoice_filenames = [] # type: List[Tuple[str, str]] for filename in os.listdir(self.directory): @@ -570,7 +580,7 @@ def _get_invoice(self, results: SourceResults, order_id: str, invoice_filename: invoice = self.pickler.load(results, invoice_path) # type: Optional[Order] if invoice is None: self.log_status('amazon: processing %s: %s' % (order_id, invoice_path, )) - invoice = parse_invoice(invoice_path) + invoice = parse_invoice(invoice_path, locale=self.locale) self.pickler.dump( results, invoice_path, invoice ) self._cached_invoices[invoice_filename] = invoice, invoice_path @@ -605,7 +615,8 @@ def prepare(self, journal: JournalEditor, results: SourceResults): invoice=invoice, posttax_adjustment_accounts=self.posttax_adjustment_accounts, amazon_account=self.amazon_account, - credit_card_accounts=credit_card_accounts) + credit_card_accounts=credit_card_accounts, + payee=self.locale.payee) results.add_pending_entry( ImportResult( date=transaction.date, diff --git a/beancount_import/source/amazon_invoice.py b/beancount_import/source/amazon_invoice.py index 7ce2faa4..cc533f03 100644 --- a/beancount_import/source/amazon_invoice.py +++ b/beancount_import/source/amazon_invoice.py @@ -1,11 +1,42 @@ -"""Parses an Amazon.com regular or digital order details HTML file.""" - +"""Parses an Amazon.com/.de regular or digital order details HTML file. + +Hierarchy of functions for parsing Amazon invoices: + +main(...) + | + + parse_invoice(...) + | | + | + parse_digital_order_invoice(...) + | | | + | | + parse_credit_card_transactions_from_payments_table(...) + | | +-> returns Order(..., shipments, ...) + | | + | + parse_regular_order_invoice(...) + | | + | + parse_shipments(...) + | | + parse_shipment_payments(...) + | | | +-> returns Shipment + | | +-> returns List[Shipment] + | | + | + parse_gift_cards(...) + | | + parse_shipment_payments(...) + | | | +-> returns Shipment + | | +-> returns List[Shipment] + | | + | + parse_credit_card_transactions(...) + | + parse_credit_card_transactions_from_payments_table(...) + | +-> returns Order(..., shipments, ...) + | + +-> returns Order +""" from typing import NamedTuple, Optional, List, Union, Iterable, Dict, Sequence, cast +from abc import ABC, abstractmethod import collections import re import os import functools import datetime +import logging import bs4 import dateutil.parser @@ -15,6 +46,254 @@ from ..amount_parsing import parse_amount, parse_number +logger = logging.getLogger('amazon_invoice') + + +class Locale_Data(ABC): + LOCALE: str + tax_included_in_price: bool + payee: str + currency: str # only used for assumed prices + + # common fields regular and digital orders + items_ordered: str + price: str + items_subtotal: str + total_before_tax: str + pretax_adjustment_fields_pattern: str + posttax_adjustment_fields_pattern: str + + # Payment Table & Credit Card Transactions + grand_total: str + credit_card_transactions: str + credit_card_last_digits: str + payment_type: List[str] + payment_information: str + + # regular orders only + shipment_shipped_pattern: str + shipment_nonshipped_headers: List[str] + shipment_quantity: str + shipment_of: str + shipment_sales_tax: str + shipment_total: str + shipment_seller_profile: str + shipment_sold_by: str + shipment_condition: str + regular_total_order: str + regular_estimated_tax: str + regular_order_placed: str + regular_order_id: str + gift_card: Optional[str] + gift_card_to: Optional[str] + gift_card_amazon_account: Optional[str] + + # digital orders only + digital_order: str + digital_order_cancelled: str + digital_by: str + digital_sold_by: str + digital_tax_collected: str + digital_total_order: str + digital_order_id: str + digital_payment_information: str + + @staticmethod + @abstractmethod + def parse_amount(amount, assumed_currency=None) -> Amount: + raise NotImplementedError + + @staticmethod + @abstractmethod + def parse_date(date_str) -> datetime.date: + raise NotImplementedError + + +class Locale_en_US(Locale_Data): + """Language and region specific settings for parsing amazon.com invoices + """ + LOCALE='en_US' + tax_included_in_price=False + payee='Amazon.com' + currency='USD' # only used for assumed prices + + # common fields regular and digital orders + items_ordered='Items Ordered' + price='Price' + items_subtotal=r'Item\(s\) Subtotal:' + total_before_tax='Total Before Tax:' + pretax_adjustment_fields_pattern=('(?:' + '|'.join([ + 'Shipping & Handling', + 'Free Shipping', + 'Free delivery', + 'Pantry delivery', + 'Promotion(?:s| Applied)', + 'Lightning Deal', + 'Your Coupon Savings', + '[0-9]+% off savings', + 'Subscribe & Save', + '[0-9]+ Audible Credit Applied', + '.*[0-9]+% Off.*', + 'Courtesy Credit', + 'Extra Savings', + '(?:.*) Discount', + 'Gift[ -]Wrap', + ]) + ') *:') + posttax_adjustment_fields_pattern=r'Gift Card Amount:|Rewards Points:|Tip [(]optional[)]:|Recycle Fee \$X' + + # Payment Table & Credit Card Transactions + grand_total=r'\n\s*Grand Total:\s+(.*)\n' + credit_card_transactions='Credit Card transactions' + credit_card_last_digits=r'^([^:]+) ending in ([0-9]+):\s+([^:]+):$' + payment_type=[ + # only first matching regex is used! + r'\n\s*([^\s|][^|\n]*[^|\s])\s+\|\s+Last (?:4 )?digits:\s+([0-9]{4})\n', + r'\n\s*(.+)\s+ending in\s+([0-9]{4})\n' + ] + payment_information='^Payment information$' + + # regular orders only + shipment_shipped_pattern='^Shipped on ([^\\n]+)$' + shipment_nonshipped_headers=[ + 'Service completed', + 'Preparing for Shipment', + 'Not Yet Shipped', + 'Shipping now' + # unknown shipment statuses will be ignored + # transaction total will not match + ] + shipment_quantity=r'^\s*(?:(?P[0-9]+)|(?P[0-9.]+\s+(?:lb|kg))|(?:(?P[0-9.]+) [(](?P[^)]+)[)]))\s+of:' + shipment_of='of:' + shipment_sales_tax='Sales Tax:' + shipment_total='Total for This Shipment:' + shipment_seller_profile=' (seller profile)' + shipment_sold_by=r'(?P.*)\n\s*(?:Sold|Provided) by:? (?P[^\n]+)' + shipment_condition=r'\n.*\n\s*Condition: (?P[^\n]+)' + regular_total_order='Grand Total:' + regular_estimated_tax = 'Estimated tax to be collected:' + regular_order_placed=r'(?:Subscribe and Save )?Order Placed:\s+([^\s]+ \d+, \d{4})' + regular_order_id=r'.*Order ([0-9\-]+)' + + # digital orders only + digital_order='Digital Order: (.*)' + digital_order_cancelled='Order Canceled' + digital_by='By' + digital_sold_by=r'Sold\s+By' + digital_tax_collected='Tax Collected:' + digital_total_order='Total for this Order:' + digital_order_id='^Amazon.com\\s+order number:\\s+(D[0-9-]+)$' + digital_payment_information='Payment Information' + + @staticmethod + def parse_amount(amount, assumed_currency=None) -> Amount: + return parse_amount(amount, assumed_currency=assumed_currency) + + @staticmethod + def parse_date(date_str) -> datetime.date: + return dateutil.parser.parse(date_str).date() + + +class Locale_de_DE(Locale_Data): + """Language and region specific settings for parsing amazon.de invoices + """ + LOCALE='de_DE' + tax_included_in_price=True # no separate tax transactions + payee='Amazon.de' + currency='EUR' # only used for assumed prices + + # common fields regular and digital orders + items_ordered='Bestellte Artikel|Erhalten|Versendet|Amazon-Konto erfolgreich aufgeladen' # Erhalten|Versendet for gift cards + price='Preis|Betrag' + items_subtotal='Zwischensumme:' + total_before_tax='Summe ohne MwSt.:' + # most of translations still missing ... + pretax_adjustment_fields_pattern=('(?:' + '|'.join([ + 'Verpackung & Versand', + # 'Free Shipping', 'Free delivery', 'Pantry delivery', + # 'Promotion(?:s| Applied)', 'Lightning Deal', + # 'Your Coupon Savings', '[0-9]+% off savings', + # 'Subscribe & Save', '[0-9]+ Audible Credit Applied', + # '.*[0-9]+% Off.*', 'Courtesy Credit', + # 'Extra Savings', '(?:.*) Discount', 'Gift[ -]Wrap', + ]) + ') *:') + # most adjustments in DE are posttax: + posttax_adjustment_fields_pattern='Gutschein eingelöst:|Geschenkgutschein\(e\):' + + # Payment Table & Credit Card Transactions + grand_total=r'\n\s*(?:Gesamtsumme|Endsumme):\s+(.*)\n' # regular: Gesamtsumme, digital: Endsumme + credit_card_transactions='Kreditkarten-Transaktionen' + credit_card_last_digits=r'^([^:]+) mit den Endziffern ([0-9]+):\s+([^:]+):$' + payment_type=[ + # only first matching regex is used! + r'\n\s*([^\s|][^|\n]*[^|\s])\s+\|\s+Die letzten (?:4 )?Ziffern:\s*([0-9]{3,4})', # 3 digits for Bankeinzug + r'\n\s*(.+)\s+mit den Endziffern\s+([0-9]{4})\n' + ] + payment_information='^Zahlungsdaten$' + + # regular orders only + shipment_shipped_pattern='^versandt am ([^\\n]+)$' + shipment_nonshipped_headers=[ + 'Versand wird vorbereitet', + 'Versand in Kürze', + # additional cases missing? + # unknown shipment statuses will be ignored + # transaction total will not match + ] + shipment_quantity=r'^\s*(?:(?P[0-9]+)|(?P[0-9.]+\s+(?:lb|kg))|(?:(?P[0-9.]+) [(](?P[^)]+)[)]))\s+Exemplar\(e\)\svon:' + shipment_of='Exemplar(e) von:' + shipment_sales_tax='Anzurechnende MwSt.:' # not sure (only old invoices) + shipment_total='Gesamtsumme:' + shipment_seller_profile=' (Mitgliedsprofil)' + shipment_sold_by=r'(?P.*)\n\s*(?:Verkauf) durch:? (?P[^\n]+)' + shipment_condition=r'\n.*\n\s*Zustand: (?P[^\n]+)' + regular_total_order='Gesamtsumme:' + regular_estimated_tax='Anzurechnende MwSt.:' + regular_order_placed=r'(?:Getätigte Spar-Abo-Bestellung|Bestellung aufgegeben am):\s+(\d+\. [^\s]+ \d{4})' + regular_order_id=r'.*Bestellung ([0-9\-]+)' + gift_card='Geschenkgutscheine' + gift_card_to=r'^(?PGeschenkgutschein)[\w\s-]*:\s*(?P[\w@._-]*)$' + gift_card_amazon_account=r'^[\w\s-]*(?PAmazon-Konto)[\w\s-]*(?Paufgeladen)[\w\s-]*$' + + # digital orders only + digital_order_cancelled='Order Canceled' + digital_order='Digitale Bestellung: (.*)' + digital_by='Von' + digital_sold_by=r'Verkauft von' + digital_tax_collected='MwSt:' + digital_total_order='Endsumme:' + digital_order_id='^Amazon.de\\s+Bestellnummer:\\s+(D[0-9-]+)$' + digital_payment_information='Zahlungsinformation' + + @staticmethod + def _format_number_str(value: str) -> str: + # 12.345,67 EUR -> 12345.67 EUR + thousands_sep = '.' + decimal_sep = ',' + return value.replace(thousands_sep, '').replace(decimal_sep, '.') + + @staticmethod + def parse_amount(amount: str, assumed_currency=None) -> Amount: + if amount is None: + return None + else: + return parse_amount( + Locale_de_DE._format_number_str(amount), + assumed_currency=assumed_currency) + + class _parserinfo(dateutil.parser.parserinfo): + MONTHS=[ + ('Jan', 'Januar'), ('Feb', 'Februar'), ('Mär', 'März'), + ('Apr', 'April'), ('Mai', 'Mai'), ('Jun', 'Juni'), + ('Jul', 'Juli'), ('Aug', 'August'), ('Sep', 'September'), + ('Okt', 'Oktober'), ('Nov', 'November'), ('Dez', 'Dezember') + ] + + @staticmethod + def parse_date(date_str) -> datetime.date: + return dateutil.parser.parse(date_str, parserinfo=Locale_de_DE._parserinfo(dayfirst=True)).date() + + +LOCALES = {x.LOCALE: x for x in [Locale_en_US, Locale_de_DE]} Errors = List[str] Adjustment = NamedTuple('Adjustment', [ @@ -24,7 +303,7 @@ Item = NamedTuple('Item', [ ('quantity', Decimal), ('description', str), - ('sold_by', str), + ('sold_by', Optional[str]), ('condition', Optional[str]), ('price', Amount), ]) @@ -41,10 +320,10 @@ ('shipped_date', Optional[datetime.date]), ('items', Sequence[Union[Item, DigitalItem]]), ('items_subtotal', Amount), - ('pretax_adjustments', Sequence[Adjustment]), + ('pretax_adjustments', List[Adjustment]), ('total_before_tax', Amount), ('posttax_adjustments', Sequence[Adjustment]), - ('tax', Amount), + ('tax', List[Adjustment]), ('total', Amount), ('errors', Errors), ]) @@ -65,26 +344,6 @@ ('errors', Errors), ]) -pretax_adjustment_fields_pattern = ('(?:' + '|'.join([ - 'Shipping & Handling', - 'Free Shipping', - 'Free delivery', - 'Pantry delivery', - 'Promotion(?:s| Applied)', - 'Lightning Deal', - 'Your Coupon Savings', - '[0-9]+% off savings', - 'Subscribe & Save', - '[0-9]+ Audible Credit Applied', - '.*[0-9]+% Off.*', - 'Courtesy Credit', - 'Extra Savings', - '(?:.*) Discount', - 'Gift[ -]Wrap', -]) + ') *:') -posttax_adjustment_fields_pattern = r'Gift Card Amount:|Rewards Points:|Tip [(]optional[)]:|Recycle Fee \$X' - - def to_json(obj): if hasattr(obj, '_asdict'): return to_json(obj._asdict()) @@ -100,6 +359,8 @@ def to_json(obj): def add_amount(a: Optional[Amount], b: Optional[Amount]) -> Optional[Amount]: + """Add two amounts, amounts with value `None` are ignored. + """ if a is None: return b if b is None: @@ -108,6 +369,8 @@ def add_amount(a: Optional[Amount], b: Optional[Amount]) -> Optional[Amount]: def reduce_amounts(amounts: Iterable[Amount]) -> Optional[Amount]: + """Reduce iterable of amounts to sum by applying `add_amount`. + """ return functools.reduce(add_amount, amounts, None) @@ -129,71 +392,86 @@ def predicate(node): return results -def get_adjustments_in_table(table, pattern, assumed_currency=None): +def get_adjustments_in_table( + table, pattern, assumed_currency=None, locale=Locale_en_US) -> List[Adjustment]: + """ Parse price adjustments in shipping or payment tables. Returns list of adjustments. + """ adjustments = [] for label, amount_str in get_field_in_table( table, pattern, allow_multiple=True, return_label=True): adjustments.append( - Adjustment(amount=parse_amount(amount_str, assumed_currency), - description=label)) + Adjustment(amount=locale.parse_amount(amount_str, assumed_currency), + description=label)) return adjustments -def reduce_adjustments(adjustments: List[Adjustment]) -> List[Adjustment]: +def reduce_adjustments(adjustments: Sequence[Adjustment]) -> Sequence[Adjustment]: + """ Takes list of adjustments and reduces duplicates by summing up the amounts. + """ + # create dict like {adjustment: [amount1, amount2, ...]} all_adjustments = collections.OrderedDict() # type: Dict[str, List[Amount]] for adjustment in adjustments: all_adjustments.setdefault(adjustment.description, []).append(adjustment.amount) + # sum over amounts and convert back to list of Adjustment return [ Adjustment(k, reduce_amounts(v)) for k, v in all_adjustments.items() ] -def parse_shipments(soup) -> List[Shipment]: - - shipped_pattern = '^Shipped on ([^\\n]+)$' - nonshipped_headers = { - 'Service completed', - 'Preparing for Shipment', - 'Not Yet Shipped', - 'Shipping now' - } - +def is_items_ordered_header(node, locale=Locale_en_US) -> bool: + """ + Identify Header of Items Ordered table (within shipment table) + """ + if node.name != 'tr': + return False + tds = node('td') + if len(tds) < 2: + return False + m1 = re.match(locale.items_ordered, tds[0].text.strip()) + m2 = re.match(locale.price, tds[1].text.strip()) + return(m1 is not None and m2 is not None) + + +def parse_shipments(soup, locale=Locale_en_US) -> List[Shipment]: + """ + Parses Shipment Table Part of HTML document (1st Table) + """ def is_shipment_header_table(node): if node.name != 'table': return False text = node.text.strip() - m = re.match(shipped_pattern, text) - return m is not None or text in nonshipped_headers + m = re.match(locale.shipment_shipped_pattern, text) + # return True for both shipped and nonshipped table headers + return m is not None or text in locale.shipment_nonshipped_headers header_tables = soup.find_all(is_shipment_header_table) + if header_tables is []: + # no shipment tables + # e.g. if only gift cards in order + logger.debug('no shipment table found') + return [] + shipments = [] # type: List[Shipment] errors = [] # type: Errors for header_table in header_tables: + logger.debug('extracting shipped date...') text = header_table.text.strip() shipped_date = None - if text not in nonshipped_headers: - m = re.match(shipped_pattern, text) + if text not in locale.shipment_nonshipped_headers: + # extract shipped date if order already shipped + m = re.match(locale.shipment_shipped_pattern, text) assert m is not None - shipped_date = dateutil.parser.parse(m.group(1)).date() + shipped_date = locale.parse_date(m.group(1)) - items = [] + logger.debug('parsing shipment items...') + items = [] # type: List[Item] shipment_table = header_table.find_parent('table') - - def is_items_ordered_header(node): - if node.name != 'tr': - return False - tds = node('td') - if len(tds) < 2: - return False - return (tds[0].text.strip() == 'Items Ordered' and - tds[1].text.strip() == 'Price') - - items_ordered_header = shipment_table.find(is_items_ordered_header) - + items_ordered_header = shipment_table.find( + lambda node: is_items_ordered_header(node, locale)) item_rows = items_ordered_header.find_next_siblings('tr') for item_row in item_rows: @@ -202,39 +480,44 @@ def is_items_ordered_header(node): price_node = tds[1] price = price_node.text.strip() - price = parse_amount(price) if price is None: - price = Amount(D(0), 'USD') + price = Amount(D(0), locale.currency) + else: + price = locale.parse_amount(price) # 1 of: 365 Everyday Value, Potato Yellow Bag Organic, 48 Ounce # 2 (1.04 lb) of: Broccoli Crowns Conventional, 1 Each # 2.07 lb of: Pork Sausage Link Italian Mild Step 1 - pattern_quantity = r'^\s*(?:(?P[0-9]+)|(?P[0-9.]+\s+(?:lb|kg))|(?:(?P[0-9.]+) [(](?P[^)]+)[)]))\s+of:' - m = re.match(pattern_quantity, description_node.text, re.UNICODE|re.DOTALL) - quantity = 1 + m = re.match(locale.shipment_quantity, description_node.text, re.UNICODE|re.DOTALL) + + quantity = None if m is not None: # Amazon will say you got, e.g. 2 broccoli crowns at $1.69/lb - but then this code multiplies the 2 by the price listed # on the invoice, which is the total price in this case (but the per-unit price in other cases) - so if there's a quantity # and a weight, ignore the quantity and treat it as 1 # alternately, capture the weight and the per-unit price and multiply out - quantity = m.group("quantity") # ignore quantity for weight items - - if quantity is None: - #print("Unable to extract quantity, using 1: %s" % description_node.text) - quantity = D(1) + + # 'quantity' group: integer, no weight units, no decimals + quantity = m.group("quantity") + # set silently to 1 if other regex groups match + if quantity is None: + quantity = 1 else: - quantity = D(quantity) + # regex did not match at all -> log warning + quantity = 1 + errors.append("Unable to extract quantity, using 1: %s" % description_node.text) - text = description_node.text.split("of:",1)[1] + quantity = D(quantity) - pattern_without_condition = r'(?P.*)\n\s*(?:Sold|Provided) by:? (?P[^\n]+)' - pattern_with_condition = pattern_without_condition + r'\n.*\n\s*Condition: (?P[^\n]+)' + text = description_node.text.split(locale.shipment_of, 1)[1] - m = re.match(pattern_with_condition, text, re.UNICODE | re.DOTALL) + m = re.match(locale.shipment_sold_by + locale.shipment_condition, + text, re.UNICODE | re.DOTALL) if m is None: - m = re.match(pattern_without_condition, text, re.UNICODE | re.DOTALL) + m = re.match(locale.shipment_sold_by, text, re.UNICODE | re.DOTALL) if m is None: + errors.append("Could not extract item from row {}".format(text)) raise Exception("Could not extract item from row", text) description = re.sub(r'\s+', ' ', m.group('description').strip()) @@ -243,7 +526,7 @@ def is_items_ordered_header(node): condition = re.sub(r'\s+', ' ', m.group('condition').strip()) except IndexError: condition = None - suffix = ' (seller profile)' + suffix = locale.shipment_seller_profile if sold_by.endswith(suffix): sold_by = sold_by[:-len(suffix)] items.append( @@ -254,150 +537,318 @@ def is_items_ordered_header(node): condition=condition, price=price, )) + + shipments.append(parse_shipment_payments( + shipment_table, + items, + errors, + shipped_date=shipped_date, + locale=locale + )) - items_subtotal = parse_amount( - get_field_in_table(shipment_table, r'Item\(s\) Subtotal:')) - expected_items_subtotal = reduce_amounts( - beancount.core.amount.mul(x.price, D(x.quantity)) for x in items) - if (items_subtotal is not None and - expected_items_subtotal != items_subtotal): - errors.append( - 'expected items subtotal is %r, but parsed value is %r' % - (expected_items_subtotal, items_subtotal)) - - output_fields = dict() - output_fields['pretax_adjustments'] = get_adjustments_in_table( - shipment_table, pretax_adjustment_fields_pattern) - output_fields['posttax_adjustments'] = get_adjustments_in_table( - shipment_table, posttax_adjustment_fields_pattern) - pretax_parts = [items_subtotal or expected_items_subtotal] + [ - a.amount for a in output_fields['pretax_adjustments'] - ] - total_before_tax = parse_amount( - get_field_in_table(shipment_table, 'Total before tax:')) - expected_total_before_tax = reduce_amounts(pretax_parts) - if total_before_tax is None: - total_before_tax = expected_total_before_tax - elif expected_total_before_tax != total_before_tax: - errors.append( - 'expected total before tax is %s, but parsed value is %s' % - (expected_total_before_tax, total_before_tax)) - - sales_tax = get_adjustments_in_table(shipment_table, 'Sales Tax:') - - posttax_parts = ( - [total_before_tax] + [a.amount for a in sales_tax] + - [a.amount for a in output_fields['posttax_adjustments']]) - total = parse_amount( - get_field_in_table(shipment_table, 'Total for This Shipment:')) - expected_total = reduce_amounts(posttax_parts) - if total is None: - total = expected_total - elif expected_total != total: - errors.append('expected total is %s, but parsed value is %s' % - (expected_total, total)) - - shipments.append( - Shipment( - shipped_date=shipped_date, - items=items, - items_subtotal=items_subtotal, - total_before_tax=total_before_tax, - tax=sales_tax, - total=total, - errors=errors, - **output_fields)) + return shipments + +def parse_gift_cards(soup, locale=Locale_en_US) -> List[Shipment]: + """ + Parses Gift Card Table Part of HTML document (1st Table) + """ + def is_gift_card_header_table(node): + if node.name != 'table': + return False + text = node.text.strip() + m = re.match(locale.gift_card, text) + if m is not None: + # check if a matching subtable exists + sub_table = node.find_all(is_gift_card_header_table) + if sub_table == []: + # only match if it is the innermost table + return True + return False + + header_tables = soup.find_all(is_gift_card_header_table) + + if header_tables is []: + # if no gift cards in order + logger.debug('no gift card table found') + return [] + + shipments = [] # type: List[Shipment] + errors = [] # type: Errors + + for header_table in header_tables: + logger.debug('parsing gift card items...') + items = [] # type: List[Item] + + shipment_table = header_table.find_parent('table') + items_ordered_header = shipment_table.find( + lambda node: is_items_ordered_header(node, locale)) + item_rows = [items_ordered_header] + + for item_row in item_rows: + tds = item_row('td') + description_node = tds[0] + price_node = tds[1] + price = price_node.text.strip() + price = price.split('\n')[1] + + if price is None: + price = Amount(D(0), locale.currency) + else: + price = locale.parse_amount(price) + + m = re.search(locale.gift_card_to, description_node.text.strip(), re.MULTILINE|re.UNICODE) + if m is None: + # if no match is found + # check if Amazon account has been charged up + m = re.search(locale.gift_card_amazon_account, description_node.text.strip(), re.MULTILINE|re.UNICODE) + if m is None: + errors.append('Failed to extract item description') + description='' + else: + description = m.group('type').strip() + ' ' + m.group('sent_to').strip() + + items.append( + Item( + quantity=D(1), + description=description, + sold_by=None, + condition=None, + price=price, + )) + + shipments.append(parse_shipment_payments( + shipment_table, + items, + errors, + shipped_date=None, + locale=locale + )) return shipments +def parse_shipment_payments( + shipment_table, + items, errors, + shipped_date=None, + locale=Locale_en_US) -> Shipment: + """ Parse payment information of single shipments and gift card orders. + """ + logger.debug('parsing shipment amounts...') + # consistency check: shipment subtotal against sum of item prices + items_subtotal = locale.parse_amount( + get_field_in_table(shipment_table, locale.items_subtotal)) + + expected_items_subtotal = reduce_amounts( + beancount.core.amount.mul(x.price, D(x.quantity)) for x in items) + if (items_subtotal is not None and + expected_items_subtotal != items_subtotal): + errors.append( + 'expected items subtotal is %r, but parsed value is %r' % + (expected_items_subtotal, items_subtotal)) + + # parse pre- and posttax adjustments for shipment + output_fields = dict() + output_fields['pretax_adjustments'] = get_adjustments_in_table( + shipment_table, locale.pretax_adjustment_fields_pattern, locale=locale) + output_fields['posttax_adjustments'] = get_adjustments_in_table( + shipment_table, locale.posttax_adjustment_fields_pattern, locale=locale) + # compare total before tax + pretax_parts = [items_subtotal or expected_items_subtotal] + [ + a.amount for a in output_fields['pretax_adjustments'] + ] + expected_total_before_tax = reduce_amounts(pretax_parts) + total_before_tax = locale.parse_amount( + get_field_in_table(shipment_table, locale.total_before_tax)) + if total_before_tax is None: + total_before_tax = expected_total_before_tax + elif expected_total_before_tax != total_before_tax: + errors.append( + 'expected total before tax is %s, but parsed value is %s' % + (expected_total_before_tax, total_before_tax)) + + sales_tax = get_adjustments_in_table(shipment_table, locale.shipment_sales_tax, locale=locale) + + if locale.tax_included_in_price: + # tax is already inlcuded in item prices + # do not add additional Adjustment for taxes + sales_tax = [] + + # compare total + posttax_parts = ( + [total_before_tax] + [a.amount for a in sales_tax] + + [a.amount for a in output_fields['posttax_adjustments']]) + expected_total = reduce_amounts(posttax_parts) + total = locale.parse_amount( + get_field_in_table(shipment_table, locale.shipment_total)) + if total is None: + total = expected_total + elif expected_total != total: + errors.append('expected total is %s, but parsed value is %s' % + (expected_total, total)) + + logger.debug('...finshed parsing shipment') + return Shipment( + shipped_date=shipped_date, + items=items, + items_subtotal=items_subtotal, + total_before_tax=total_before_tax, + tax=sales_tax, + total=total, + errors=errors, + **output_fields) + + def parse_credit_card_transactions_from_payments_table( payment_table, - order_date: datetime.date) -> List[CreditCardTransaction]: + order_date: datetime.date, + locale=Locale_en_US) -> Sequence[CreditCardTransaction]: + """ Parse payment information from payments table. + Only type and last digits are given, no amount (assuming grand total). + Other payment methods than credit card are possible: + - Direct Debit (DE: Bankeinzug) + """ payment_text = '\n'.join(payment_table.strings) - m = re.search(r'\n\s*Grand Total:\s+(.*)\n', payment_text) + m = re.search(locale.grand_total, payment_text) assert m is not None - grand_total = parse_amount(m.group(1).strip()) + grand_total = locale.parse_amount(m.group(1).strip()) + + for regex in locale.payment_type: + m = re.search(regex, payment_text) + if m is not None: + # only take first matching regex, discard others! + break - m = re.search( - r'\n\s*([^\s|][^|\n]*[^|\s])\s+\|\s+Last (?:4 )?digits:\s+([0-9]{4})\n', - payment_text) if m is None: - m = re.search(r'\n\s*(.+)\s+ending in\s+([0-9]{4})\n', payment_text) + return [] - if m is not None: - credit_card_transactions = [ - CreditCardTransaction( - date=order_date, - amount=grand_total, - card_description=m.group(1).strip(), - card_ending_in=m.group(2).strip(), - ) - ] - else: - credit_card_transactions = [] + credit_card_transactions = [ + CreditCardTransaction( + date=order_date, + amount=grand_total, + card_description=m.group(1).strip(), + card_ending_in=m.group(2).strip(), + ) + ] return credit_card_transactions -def parse_credit_card_transactions(soup) -> List[CreditCardTransaction]: +def parse_credit_card_transactions(soup, locale=Locale_en_US) -> Sequence[CreditCardTransaction]: + """ Parse Credit Card Transactions from bottom sub-table of payments table. + Transactions are listed with type, 4 digits, transaction date and amount. + """ def is_header_node(node): return node.name == 'td' and node.text.strip( - ) == 'Credit Card transactions' + ) == locale.credit_card_transactions header_node = soup.find(is_header_node) if header_node is None: return [] sibling = header_node.find_next_sibling('td') rows = sibling.find_all('tr') - transactions = [] + transactions = [] # type: List[CreditCardTransaction] for row in rows: if not row.text.strip(): continue tds = row('td') description = tds[0].text.strip() amount_text = tds[1].text.strip() - m = re.match(r'^([^:]+) ending in ([0-9]+):\s+([^:]+):$', description, - re.UNICODE) + m = re.match(locale.credit_card_last_digits, description, + re.UNICODE) assert m is not None transactions.append( CreditCardTransaction( - date=dateutil.parser.parse(m.group(3)).date(), + date=locale.parse_date(m.group(3)), card_description=m.group(1), card_ending_in=m.group(2), - amount=parse_amount(amount_text), + amount=locale.parse_amount(amount_text), )) return transactions -def parse_invoice(path: str) -> Optional[Order]: +def parse_invoice(path: str, locale=Locale_en_US) -> Optional[Order]: + """ 1st method to call, distinguish between regular and digital invoice. + """ if os.path.basename(path).startswith('D'): - return parse_digital_order_invoice(path) - return parse_regular_order_invoice(path) - - -def parse_regular_order_invoice(path: str) -> Order: - errors = [] + logger.debug('identified as digital invoice') + return parse_digital_order_invoice(path, locale=locale) + logger.debug('identified as regular invoice') + return parse_regular_order_invoice(path, locale=locale) + + +def parse_regular_order_invoice(path: str, locale=Locale_en_US) -> Order: + """ Parse regular order type invoice (HTML document) + 1. parse all shipment tables with individual items + 2. parse payment table + 3. sanity check totals extracted from item prices and payment table + """ + errors = [] # type: Errors with open(path, 'rb') as f: soup = bs4.BeautifulSoup(f.read(), 'lxml') - shipments = parse_shipments(soup) + + # ----------------- + # Order ID & Order placed date + # ----------------- + logger.debug('parsing order id and order placed date...') + title = soup.find('title').text.strip() + m = re.fullmatch(locale.regular_order_id, title.strip()) + assert m is not None + order_id=m.group(1) + + def is_order_placed_node(node): + m = re.fullmatch(locale.regular_order_placed, node.text.strip()) + return m is not None + + node = soup.find(is_order_placed_node) + m = re.fullmatch(locale.regular_order_placed, node.text.strip()) + assert m is not None + order_date = locale.parse_date(m.group(1)) + + # ---------------------- + # Shipments & Gift Cards + # ---------------------- + logger.debug('parsing shipments...') + shipments = parse_shipments(soup, locale=locale) + if hasattr(locale, 'gift_card'): + shipments += parse_gift_cards(soup, locale=locale) + if len(shipments) == 0: + # no shipment or gift card tables found + msg = ('Identified regular order invoice but no items were found ' + + '(neither shipments nor gift cards). This may be a new type. ' + + 'Consider opening an issue at jbms/beancount-import on github.') + logger.warning(msg) + errors.append(msg) + # do not throw exception, continue parsing the payment table + logger.debug('finished parsing shipments') + + # ------------------------------------------- + # Payment Table: Pre- and Posttax Adjustments + # ------------------------------------------- + # Aim: Parse all pre- and posttax adjustments + # consistency check grand total against sum of item costs + logger.debug('parsing payment table...') payment_table_header = soup.find( - lambda node: node.name == 'table' and re.match('^Payment information$', node.text.strip())) + lambda node: node.name == 'table' and re.match( + locale.payment_information, node.text.strip())) payment_table = payment_table_header.find_parent('table') - output_fields = dict() + logger.debug('parsing pretax adjustments...') + output_fields = dict() # type: Dict[str, List[Adjustment]] output_fields['pretax_adjustments'] = get_adjustments_in_table( - payment_table, pretax_adjustment_fields_pattern) - payment_adjustments = collections.OrderedDict() # type: Dict[str, Amount] - + payment_table, locale.pretax_adjustment_fields_pattern, locale=locale) + # older invoices put pre-tax amounts on a per-shipment basis # new invoices only put pre-tax amounts on the overall payments section # detect which this is + + # payment table pretax adjustments pretax_amount = reduce_amounts( a.amount for a in output_fields['pretax_adjustments']) + shipments_pretax_amount = None - if any(s.pretax_adjustments for s in shipments): + # sum over all shipment pretax amounts shipments_pretax_amount = reduce_amounts(a.amount for shipment in shipments for a in shipment.pretax_adjustments) @@ -407,25 +858,39 @@ def parse_regular_order_invoice(path: str) -> Order: 'expected total pretax adjustment to be %s, but parsed total is %s' % (shipments_pretax_amount, pretax_amount)) - payments_total_adjustments = [] - shipments_total_adjustments = [] - + logger.debug('parsing posttax adjustments...') # parse first to get an idea of the working currency - grand_total = parse_amount( - get_field_in_table(payment_table, 'Grand Total:')) + grand_total = locale.parse_amount( + get_field_in_table(payment_table, locale.regular_total_order)) - def resolve_posttax_adjustments(): + payment_adjustments = collections.OrderedDict() # type: Dict[str, Amount] + payments_total_adjustments = [] # type: List[Amount] + shipments_total_adjustments = [] # type: List[Amount] + + def resolve_posttax_adjustments() -> List[Adjustment]: + """ Extract and compare posttax adjustments + from shipment and payment tables. + Returns list of reduced Adjustments. + """ + # get reduced form of adjustments from payment table payment_adjustments.update( reduce_adjustments( get_adjustments_in_table(payment_table, - posttax_adjustment_fields_pattern, - assumed_currency=grand_total.currency))) + locale.posttax_adjustment_fields_pattern, + assumed_currency=grand_total.currency, + locale=locale))) + # adjustments from all shipments, reduced all_shipments_adjustments = collections.OrderedDict( reduce_adjustments( sum((x.posttax_adjustments for x in shipments), []))) + + # initialize dict with all adjustment keys, values not used + # dict ensures that keys are unique all_keys = collections.OrderedDict(payment_adjustments.items()) all_keys.update(all_shipments_adjustments.items()) - + + # combine shipment and payment adjustments + # make sure that shipment adjustments match payment adjustments all_adjustments = collections.OrderedDict() # type: Dict[str, Amount] for key in all_keys: payment_amount = payment_adjustments.get(key) @@ -439,6 +904,7 @@ def resolve_posttax_adjustments(): # Amazon sometimes doesn't include these adjustments in the Shipment table shipments_total_adjustments.append(amount) elif payment_amount != shipments_amount: + # Both tables include adjustment with same label, but amount does not match errors.append( 'expected total %r to be %s, but parsed total is %s' % (key, shipments_amount, payment_amount)) @@ -447,17 +913,29 @@ def resolve_posttax_adjustments(): output_fields['posttax_adjustments'] = resolve_posttax_adjustments() - tax = parse_amount( - get_field_in_table(payment_table, 'Estimated tax to be collected:')) + logger.debug('consistency check taxes...') + # tax from payment table + tax = locale.parse_amount( + get_field_in_table(payment_table, locale.regular_estimated_tax)) + # tax from shipment tables expected_tax = reduce_amounts( a.amount for shipment in shipments for a in shipment.tax) if expected_tax is None: - shipments_total_adjustments.append(tax) + # tax not given on shipment level + if not locale.tax_included_in_price: + # add tax to adjustments if not already included in item prices + shipments_total_adjustments.append(tax) elif expected_tax != tax: errors.append( 'expected tax is %s, but parsed value is %s' % (expected_tax, tax)) + if locale.tax_included_in_price: + # tax is already inlcuded in item prices + # do not add additional transaction for taxes + tax = None + + logger.debug('consistency check grand total...') payments_total_adjustment = reduce_amounts(payments_total_adjustments) shipments_total_adjustment = reduce_amounts(shipments_total_adjustments) @@ -472,39 +950,33 @@ def resolve_posttax_adjustments(): adjusted_grand_total = add_amount(payments_total_adjustment, grand_total) if expected_total != adjusted_grand_total: errors.append('expected grand total is %s, but parsed value is %s' % - (expected_total, adjusted_grand_total)) - order_placed_pattern = r'(?:Subscribe and Save )?Order Placed:\s+([^\s]+ \d+, \d{4})' - - def is_order_placed_node(node): - m = re.fullmatch(order_placed_pattern, node.text.strip()) - return m is not None - - node = soup.find(is_order_placed_node) - m = re.fullmatch(order_placed_pattern, node.text.strip()) - assert m is not None - order_date = dateutil.parser.parse(m.group(1)).date() + (expected_total, adjusted_grand_total)) - credit_card_transactions = parse_credit_card_transactions(soup) + # --------------------------------------- + # Payment Table: Credit Card Transactions + # --------------------------------------- + logger.debug('parsing credit card transactions...') + credit_card_transactions = parse_credit_card_transactions(soup, locale=locale) if not credit_card_transactions: + # no explicit credit card transaction table + logger.debug('no credit card transactions table given, falling back to payments table') credit_card_transactions = parse_credit_card_transactions_from_payments_table( - payment_table, order_date) + payment_table, order_date, locale=locale) if credit_card_transactions: total_payments = reduce_amounts( x.amount for x in credit_card_transactions) else: - total_payments = Amount(number=ZERO, currency=grand_total.currency) + logger.debug('no payment transactions found, assumig grand total as total payment amount') + total_payments = grand_total if total_payments != adjusted_grand_total: errors.append('total payment amount is %s, but grand total is %s' % (total_payments, adjusted_grand_total)) - title = soup.find('title').text.strip() - m = re.fullmatch(r'.*Order ([0-9\-]+)', title.strip()) - assert m is not None - + logger.debug('...finished parsing regular invoice.') return Order( order_date=order_date, - order_id=m.group(1), + order_id=order_id, shipments=shipments, credit_card_transactions=credit_card_transactions, tax=tax, @@ -514,6 +986,8 @@ def is_order_placed_node(node): def get_text_lines(parent_node): + """ Format nodes into list of strings + """ text_lines = [''] for node in parent_node.children: if isinstance(node, bs4.NavigableString): @@ -525,57 +999,74 @@ def get_text_lines(parent_node): return text_lines -def parse_digital_order_invoice(path: str) -> Optional[Order]: - errors = [] +def parse_digital_order_invoice(path: str, locale=Locale_en_US) -> Optional[Order]: + """ Parse digital order type invoice (HTML document) + 1. parse all digital items tables + 2. parse amounts + 3. parse payment table + """ + errors = [] # type: Errors with open(path, 'rb') as f: soup = bs4.BeautifulSoup(f.read(), 'lxml') + logger.debug('check if order has been cancelled...') def is_cancelled_order(node): - return node.text.strip() == 'Order Canceled' + return node.text.strip() == locale.digital_order_cancelled if soup.find(is_cancelled_order): - return None - - digital_order_pattern = 'Digital Order: (.*)' + return None + # -------------------------------------------------- + # Find Digital Order Header, parse date and order ID + # -------------------------------------------------- + logger.debug('parsing header...') def is_digital_order_row(node): if node.name != 'tr': return False - m = re.match(digital_order_pattern, node.text.strip()) + m = re.match(locale.digital_order, node.text.strip()) if m is None: return False try: - dateutil.parser.parse(m.group(1)) + locale.parse_date(m.group(1)) return True except: return False - # Find Digital Order row digital_order_header = soup.find(is_digital_order_row) digital_order_table = digital_order_header.find_parent('table') - m = re.match(digital_order_pattern, digital_order_header.text.strip()) - assert m is not None - order_date = dateutil.parser.parse(m.group(1)).date() - - def is_items_ordered_header(node): - if node.name != 'tr': - return False - tds = node('td') - if len(tds) < 2: - return False - return (tds[0].text.strip() == 'Items Ordered' and - tds[1].text.strip() == 'Price') + m = re.match(locale.digital_order, digital_order_header.text.strip()) + if m is None: + msg = ('Identified digital order invoice but no digital orders were found.') + logger.warning(msg) + errors.append(msg) + # throw exception since there is no other possibility to get order_date + assert m is not None + order_date = locale.parse_date(m.group(1)) - items_ordered_header = digital_order_table.find(is_items_ordered_header) + order_id_td = soup.find( + lambda node: node.name == 'td' and + re.match(locale.digital_order_id, node.text.strip()) + ) + m = re.match(locale.digital_order_id, order_id_td.text.strip()) + assert m is not None + order_id = m.group(1) + # ----------- + # Parse Items + # ----------- + logger.debug('parsing items...') + items_ordered_header = digital_order_table.find( + lambda node: is_items_ordered_header(node, locale)) item_rows = items_ordered_header.find_next_siblings('tr') - items = [] - + + items = [] # Sequence[DigitalItem] other_fields_td = None for item_row in item_rows: tds = item_row('td') if len(tds) != 2: + # payment information on order level (not payment table) + # differently formatted, take first column only other_fields_td = tds[0] continue description_node = tds[0] @@ -596,13 +1087,13 @@ def is_items_ordered_header(node): def get_label_value(label): for line in text_lines: m = re.match(r'^\s*' + label + ': (.*)$', line, - re.UNICODE | re.DOTALL) + re.UNICODE | re.DOTALL) if m is None: continue return m.group(1) - by = get_label_value('By') - sold_by = get_label_value(r'Sold\s+By') + by = get_label_value(locale.digital_by) + sold_by = get_label_value(locale.digital_sold_by) items.append( DigitalItem( @@ -610,12 +1101,18 @@ def get_label_value(label): by=by, sold_by=sold_by, url=url, - price=parse_amount(price), + price=locale.parse_amount(price), )) other_fields_text_lines = get_text_lines(other_fields_td) + # ------------------------------------------- + # Parse Amounts, Pre- and Posttax Adjustments + # ------------------------------------------- + logger.debug('parsing amounts...') def get_other_field(pattern, allow_multiple=False, return_label=False): + """ Look for pattern in other_fields_text_lines + """ results = [] for line in other_fields_text_lines: r = r'^\s*(' + pattern + r')\s+(.*[^\s])\s*$' @@ -635,39 +1132,51 @@ def get_adjustments(pattern): for label, amount_str in get_other_field( pattern, allow_multiple=True, return_label=True): adjustments.append( - Adjustment(amount=parse_amount(amount_str), description=label)) + Adjustment(amount=locale.parse_amount(amount_str), description=label)) return adjustments def get_amounts_in_text(pattern_map): amounts = dict() for key, label in pattern_map.items(): - amount = parse_amount(get_other_field(label)) + amount = locale.parse_amount(get_other_field(label)) amounts[key] = amount return amounts - - items_subtotal = parse_amount(get_other_field(r'Item\(s\) Subtotal:')) - total_before_tax = parse_amount(get_other_field('Total Before Tax:')) - tax = get_adjustments('Tax Collected:') - total_for_this_order = parse_amount( - get_other_field('Total for this Order:')) + + items_subtotal = locale.parse_amount( + get_other_field(locale.items_subtotal)) + total_before_tax = locale.parse_amount( + get_other_field(locale.total_before_tax)) + tax = get_adjustments(locale.digital_tax_collected) + total_for_this_order = locale.parse_amount( + get_other_field(locale.digital_total_order)) + + logger.debug('parsing pretax adjustments...') output_fields = dict() output_fields['pretax_adjustments'] = get_adjustments( - pretax_adjustment_fields_pattern) + locale.pretax_adjustment_fields_pattern) pretax_parts = ([items_subtotal] + [a.amount for a in output_fields['pretax_adjustments']]) expected_total_before_tax = reduce_amounts(pretax_parts) if expected_total_before_tax != total_before_tax: errors.append('expected total before tax is %s, but parsed value is %s' - % (expected_total_before_tax, total_before_tax)) + % (expected_total_before_tax, total_before_tax)) + + logger.debug('parsing posttax adjustments...') output_fields['posttax_adjustments'] = get_adjustments( - posttax_adjustment_fields_pattern) + locale.posttax_adjustment_fields_pattern) posttax_parts = ([total_before_tax] + [a.amount for a in tax] + [a.amount for a in output_fields['posttax_adjustments']]) expected_total = reduce_amounts(posttax_parts) + if expected_total != total_for_this_order: errors.append('expected total is %s, but parsed value is %s' % (expected_total, total_for_this_order)) + if locale.tax_included_in_price: + # tax is already inlcuded in item prices + # do not add additional transaction for taxes + tax = [] + shipment = Shipment( shipped_date=order_date, items=items, @@ -678,18 +1187,18 @@ def get_amounts_in_text(pattern_map): errors=errors, **output_fields) - order_id_pattern = '^Amazon.com\\s+order number:\\s+(D[0-9-]+)$' - - order_id_td = soup.find(lambda node: node.name == 'td' and re.match(order_id_pattern, node.text.strip())) - m = re.match(order_id_pattern, order_id_td.text.strip()) - assert m is not None - order_id = m.group(1) - + # ------------- + # Payment Table + # ------------- + logger.debug('parsing payment information...') payment_table = soup.find( - lambda node: node.name == 'table' and node.text.strip().startswith('Payment Information') - ) + lambda node: node.name == 'table' and + node.text.strip().startswith(locale.digital_payment_information) + ) credit_card_transactions = parse_credit_card_transactions_from_payments_table( - payment_table, order_date) + payment_table, order_date, locale=locale) + + logger.debug('...finished parsing digital invoice.') return Order( order_date=order_date, @@ -698,7 +1207,10 @@ def get_amounts_in_text(pattern_map): credit_card_transactions=credit_card_transactions, pretax_adjustments=[], posttax_adjustments=output_fields['posttax_adjustments'], - tax=[], + # tax given on "shipment level" + # for digital orders tax is always given on shipment level + # therefore tax on order level is irrelevant + tax=None, errors=[]) @@ -713,13 +1225,16 @@ def main(): default=False, action='store_true', help='Output in JSON format.') + ap.add_argument( + '--locale', default='en_US', help='Local Amazon settings, defaults to en_US') ap.add_argument('paths', nargs='*') args = ap.parse_args() + locale = LOCALES[args.locale] results = [] for path in args.paths: try: - result = parse_invoice(path) + result = parse_invoice(path, locale=locale) results.append(result) except: sys.stderr.write('Error reading: %s\n' % path) diff --git a/beancount_import/source/amazon_invoice_sanitize.py b/beancount_import/source/amazon_invoice_sanitize.py index 6a2da515..757e02a5 100644 --- a/beancount_import/source/amazon_invoice_sanitize.py +++ b/beancount_import/source/amazon_invoice_sanitize.py @@ -51,16 +51,27 @@ def get_replacement(m): def sanitize_credit_card(contents: str, new_digits: str): + # en_EN contents = re.sub(r'(ending in\s+)[0-9]{4}', lambda m: m.group(1) + new_digits, contents) contents = re.sub(r'(Last (?:[a-zA-Z0-9\s]*)digits:\s*)[0-9]{4}', lambda m: m.group(1) + new_digits, contents) + # de_DE + contents = re.sub(r'(mit den Endziffern\s+)[0-9]{4}', + lambda m: m.group(1) + new_digits, contents) + contents = re.sub(r'(Die letzten(?:[a-zA-Z0-9\s]*)Ziffern:\s*)[0-9]{4}', + lambda m: m.group(1) + new_digits, contents) return contents def sanitize_address(contents: str): - return re.sub( - '^.*address.*$', '', contents, flags=re.IGNORECASE | re.MULTILINE) + contents = re.sub( + '^.*displayaddress.*$', '', contents, flags=re.IGNORECASE | re.MULTILINE) + + # some invoices have shipping address given in payment table in different format (e.g. de_DE digital) + contents = re.sub( + r'
  • .*<\/ul>', '', contents) + return contents def remove_tag(soup: bs4.BeautifulSoup, tag: str): diff --git a/beancount_import/source/amazon_invoice_test.py b/beancount_import/source/amazon_invoice_test.py index bb2ac06f..f588d899 100644 --- a/beancount_import/source/amazon_invoice_test.py +++ b/beancount_import/source/amazon_invoice_test.py @@ -17,7 +17,7 @@ '166-7926740-5141621', 'D56-5204779-4181560', ]) -def test_parsing(name: str): +def test_parsing_en_US(name: str): source_path = os.path.join(testdata_dir, name + '.html') invoice = amazon_invoice.parse_invoice(source_path) json_path = os.path.join(testdata_dir, name + '.json') @@ -29,3 +29,32 @@ def test_parsing(name: str): if expected_str != actual_str: print(actual_str) assert expected_str == actual_str + + +@pytest.mark.parametrize('name', [ + '256-0244967-2403944', # regular order + '393-2608279-9292916', # Spar-Abo, payed with gift card + '898-5185906-0096901', # Spar-Abo + '974-6135682-9358749', # several credit card transactions + 'D22-9220967-2566135', # digital order, audible subscription + 'D60-9825125-4795642', # digital order + '399-5779972-5007935', # Direct Debit (Bankeinzug) + '071-4816388-0694813', # gift card amazon + '075-2225405-7594823', # gift card spotify + '447-6209054-6766419', # charge up Amazon account + '588-8509154-9761865', # preparing shipment + '142-4912939-2196263', # shipping soon +]) +def test_parsing_de_DE(name: str): + testdata_dir_locale = os.path.join(testdata_dir, 'de_DE') + source_path = os.path.join(testdata_dir_locale, name + '.html') + invoice = amazon_invoice.parse_invoice(source_path, locale=amazon_invoice.LOCALES['de_DE']()) + json_path = os.path.join(testdata_dir_locale, name + '.json') + expected = json.load( + open(json_path, 'r'), object_pairs_hook=collections.OrderedDict) + expected_str = json.dumps(expected, indent=4) + actual = amazon_invoice.to_json(invoice) + actual_str = json.dumps(actual, indent=4) + if expected_str != actual_str: + print(actual_str) + assert expected_str == actual_str diff --git a/testdata/source/amazon/D56-5204779-4181560.json b/testdata/source/amazon/D56-5204779-4181560.json index 35b62d78..09aac4e8 100644 --- a/testdata/source/amazon/D56-5204779-4181560.json +++ b/testdata/source/amazon/D56-5204779-4181560.json @@ -54,7 +54,7 @@ } ], "pretax_adjustments": [], - "tax": [], + "tax": null, "posttax_adjustments": [], "errors": [] } diff --git a/testdata/source/amazon/de_DE/071-4816388-0694813.html b/testdata/source/amazon/de_DE/071-4816388-0694813.html new file mode 100644 index 00000000..0f3b8b6a --- /dev/null +++ b/testdata/source/amazon/de_DE/071-4816388-0694813.html @@ -0,0 +1,227 @@ + + + + + + +Amazon.de - Bestellung 071-4816388-0694813 + + + + +
    +
    +
    + Übersicht zur Bestellung #071-4816388-0694813 +
    +Bitte drucken Sie diese Seite aus und legen Sie sie zu Ihren Unterlagen. +

    + + + + +
    + + + + + + +
    + + Bestellung aufgegeben am: + + 12. August 2020 +
    +Bestellnummer: + 071-4816388-0694813 + +Bestellübersicht drucken | + Rechnung drucken +
    +Gesamtbestellwert: + EUR 50,00 +
    +
    + + + + +
    + + + + + + + +
    + + + + +
    +
    Geschenkgutscheine
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + +
    + +Erhalten
    +Geschenkgutschein per E-Mail schicken an: johndoe@mail.com
    + - Von: removed
    + - Nachricht: +
    greetings message removed

    +
    +Betrag
    +EUR 50,00
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    + + + + + + + +
    + + + + +
    +
    Zahlungsdaten
    +
    +
    + + + + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + +
    Zwischensumme:EUR 50,00
     -----
    Summe:EUR 50,00
     -----
    Gesamtsumme: EUR 50,00
    +
    +Zahlungsart: +
    + + + + + + + +Visa / Electron + | Die letzten Ziffern:1234
    +
    +
    +
    +
    +
    +

    Um den Status Ihrer Bestellung einzusehen, kehren Sie auf Bestellungsübersicht zurück.

    +

    Hinweis: Dies ist keine Rechnung.

    +
    + +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 6143-7307, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/071-4816388-0694813.json b/testdata/source/amazon/de_DE/071-4816388-0694813.json new file mode 100644 index 00000000..d1b68d64 --- /dev/null +++ b/testdata/source/amazon/de_DE/071-4816388-0694813.json @@ -0,0 +1,49 @@ +{ + "order_id": "071-4816388-0694813", + "order_date": "2020-08-12", + "shipments": [ + { + "shipped_date": null, + "items": [ + { + "quantity": "1", + "description": "Geschenkgutschein johndoe@mail.com", + "sold_by": null, + "condition": null, + "price": { + "number": "50.00", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "50.00", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "50.00", + "currency": "EUR" + }, + "errors": [] + } + ], + "credit_card_transactions": [ + { + "date": "2020-08-12", + "card_description": "Visa / Electron", + "card_ending_in": "1234", + "amount": { + "number": "50.00", + "currency": "EUR" + } + } + ], + "pretax_adjustments": [], + "tax": null, + "posttax_adjustments": [], + "errors": [] +} \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/075-2225405-7594823.html b/testdata/source/amazon/de_DE/075-2225405-7594823.html new file mode 100644 index 00000000..6013ef0a --- /dev/null +++ b/testdata/source/amazon/de_DE/075-2225405-7594823.html @@ -0,0 +1,227 @@ + + + + + + +Amazon.de - Bestellung 075-2225405-7594823 + + + + +
    +
    +
    + Übersicht zur Bestellung #075-2225405-7594823 +
    +Bitte drucken Sie diese Seite aus und legen Sie sie zu Ihren Unterlagen. +

    + + + + +
    + + + + + + +
    + + Bestellung aufgegeben am: + + 1. März 2020 +
    +Bestellnummer: + 075-2225405-7594823 + +Bestellübersicht drucken | + Rechnung drucken +
    +Gesamtbestellwert: + EUR 99,00 +
    +
    + + + + +
    + + + + + + + +
    + + + + +
    +
    Geschenkgutscheine
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + +
    + +Versendet
    +Geschenkgutschein per E-Mail schicken an: johndoe@mail.com
    + - Von: removed
    + - Nachricht: +
    greetings message removed

    +
    +Betrag
    +EUR 99,00
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    + + + + + + + +
    + + + + +
    +
    Zahlungsdaten
    +
    +
    + + + + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + +
    Zwischensumme:EUR 99,00
     -----
    Summe:EUR 99,00
     -----
    Gesamtsumme: EUR 99,00
    +
    +Zahlungsart: +
    + + + + + + + +Visa / Electron + | Die letzten Ziffern:1234
    +
    +
    +
    +
    +
    +

    Um den Status Ihrer Bestellung einzusehen, kehren Sie auf Bestellungsübersicht zurück.

    +

    Hinweis: Dies ist keine Rechnung.

    +
    + +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 2252-1593, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/075-2225405-7594823.json b/testdata/source/amazon/de_DE/075-2225405-7594823.json new file mode 100644 index 00000000..fc4caa62 --- /dev/null +++ b/testdata/source/amazon/de_DE/075-2225405-7594823.json @@ -0,0 +1,49 @@ +{ + "order_id": "075-2225405-7594823", + "order_date": "2020-03-01", + "shipments": [ + { + "shipped_date": null, + "items": [ + { + "quantity": "1", + "description": "Geschenkgutschein johndoe@mail.com", + "sold_by": null, + "condition": null, + "price": { + "number": "99.00", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "99.00", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "99.00", + "currency": "EUR" + }, + "errors": [] + } + ], + "credit_card_transactions": [ + { + "date": "2020-03-01", + "card_description": "Visa / Electron", + "card_ending_in": "1234", + "amount": { + "number": "99.00", + "currency": "EUR" + } + } + ], + "pretax_adjustments": [], + "tax": null, + "posttax_adjustments": [], + "errors": [] +} \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/142-4912939-2196263.html b/testdata/source/amazon/de_DE/142-4912939-2196263.html new file mode 100644 index 00000000..b0c37cc3 --- /dev/null +++ b/testdata/source/amazon/de_DE/142-4912939-2196263.html @@ -0,0 +1,317 @@ + + + + + + +Amazon.de - Bestellung 142-4912939-2196263 + + + + +
    +
    +
    + Übersicht zur Bestellung #142-4912939-2196263 +
    +Bitte drucken Sie diese Seite aus und legen Sie sie zu Ihren Unterlagen. +

    + + + + +
    + + + + +
    + + Bestellung aufgegeben am: + + 19. Februar 2022 +
    +Bestellnummer: + 142-4912939-2196263 +
    +Gesamtbestellwert: + EUR 15,99 +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    + Versand in Kürze +
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + + + + + +
    +Bestellte Artikel + +Preis +
    + + 1 + + Exemplar(e) von: + + CS Labs Wärmeleitpaste & Pad Ersatz-Set, K5-PRO K4-PRO kompatibel mit iPhone, Mac PS4 PS3 Xbox Asus Dell usw.
    + + Verkauf durch: WWW.COMPUTER-SYSTEMS.GR (Mitgliedsprofil) + + + + + + + + + + + + + + + + + + + + + + + + | Haben Sie eine Frage zum Produkt? Frage an den Verkäufer stellen +
    +
    + + Zustand: Neu
    +
    +
    +EUR 15,99
    +
    +
    +
    +
    + + + + + +
    + +Versandadresse + +
    + + + + + + + + + +
    +Versandart: + +
    +Premiumversand +
    +
    + +
    +
    +
    +
    +
    + + + + +
    + + + + + + + +
    + + + + +
    +
    Zahlungsdaten
    +
    +
    + + + + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Zwischensumme:EUR 13,44
    Verpackung & Versand:EUR 0,00
     -----
    Summe ohne MwSt.:EUR 13,44
    Anzurechnende MwSt.:EUR 2,55
     -----
    Summe:EUR 15,99
     -----
    Gesamtsumme: EUR 15,99
    +
    +Zahlungsart: +
    + + + Bankeinzug + | Die letzten Ziffern: 600
    +
    +Rechnungsadresse: + + + + + + + + + +
    +
    +
    +
    +
    +

    Um den Status Ihrer Bestellung einzusehen, kehren Sie auf Bestellungsübersicht zurück.

    +

    Hinweis: Dies ist keine Rechnung.

    +
    + +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 6029-8252, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/142-4912939-2196263.json b/testdata/source/amazon/de_DE/142-4912939-2196263.json new file mode 100644 index 00000000..b67120d2 --- /dev/null +++ b/testdata/source/amazon/de_DE/142-4912939-2196263.json @@ -0,0 +1,57 @@ +{ + "order_id": "142-4912939-2196263", + "order_date": "2022-02-19", + "shipments": [ + { + "shipped_date": null, + "items": [ + { + "quantity": "1", + "description": "CS Labs W\u00e4rmeleitpaste & Pad Ersatz-Set, K5-PRO K4-PRO kompatibel mit iPhone, Mac PS4 PS3 Xbox Asus Dell usw.", + "sold_by": "WWW.COMPUTER-SYSTEMS.GR", + "condition": "Neu", + "price": { + "number": "15.99", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "15.99", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "15.99", + "currency": "EUR" + }, + "errors": [] + } + ], + "credit_card_transactions": [ + { + "date": "2022-02-19", + "card_description": "Bankeinzug", + "card_ending_in": "600", + "amount": { + "number": "15.99", + "currency": "EUR" + } + } + ], + "pretax_adjustments": [ + { + "description": "Verpackung & Versand", + "amount": { + "number": "0.00", + "currency": "EUR" + } + } + ], + "tax": null, + "posttax_adjustments": [], + "errors": [] +} \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/256-0244967-2403944.html b/testdata/source/amazon/de_DE/256-0244967-2403944.html new file mode 100644 index 00000000..ed841e72 --- /dev/null +++ b/testdata/source/amazon/de_DE/256-0244967-2403944.html @@ -0,0 +1,327 @@ + + + + + + +Amazon.de - Bestellung 256-0244967-2403944 + + + + +
    +
    +
    + Übersicht zur Bestellung #256-0244967-2403944 +
    +Bitte drucken Sie diese Seite aus und legen Sie sie zu Ihren Unterlagen. +

    + + + + +
    + + + + + +
    + + Bestellung aufgegeben am: + + 27. September 2021 +
    +Bestellnummer: + 256-0244967-2403944 +
    +Bestellnummer seitens des Verkäufers: + 9254259 +
    +Gesamtbestellwert: + EUR 23,96 +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    + versandt am 28. September 2021 +
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + + + + + +
    +Bestellte Artikel + +Preis +
    + + 1 + + Exemplar(e) von: + + FC Bayern München Cuddly fleece blanket 150 x 200 cm
    + + Verkauf durch: Offizieller FC Bayern Store (Mitgliedsprofil) + + + + + + + + + + + + + + + + + + +
    +
    + + Zustand: Neu
    +
    +
    +EUR 23,96
    +
    +
    +
    +
    + + + + + +
    + +Versandadresse + +
    + + + + + + + + + +
    +Versandart: + +
    +Standardversand +
    +
    + +
    +
    +
    +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    Zahlungsdaten
    +
    +
    + + + + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Zwischensumme:EUR 23,96
    Verpackung & Versand:EUR 0,00
     -----
    Summe:EUR 23,96
     -----
    Gesamtsumme: EUR 23,96
    +
    +Zahlungsart: +
    + + + Visa / Electron + | Die letzten Ziffern: 1234 +
    +
    +Rechnungsadresse: + + + + + + + + + +
    +
    + + + + + +
    +
    Kreditkarten-Transaktionen 
    +
    + + + + + +
    + Visa mit den Endziffern 1234: 28. September 2021: + +EUR 23,96 +
    +
    +
    +
    +
    +
    +

    Um den Status Ihrer Bestellung einzusehen, kehren Sie auf Bestellungsübersicht zurück.

    +

    Hinweis: Dies ist keine Rechnung.

    +
    + +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 7744-6638, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/256-0244967-2403944.json b/testdata/source/amazon/de_DE/256-0244967-2403944.json new file mode 100644 index 00000000..eaaa047d --- /dev/null +++ b/testdata/source/amazon/de_DE/256-0244967-2403944.json @@ -0,0 +1,57 @@ +{ + "order_id": "256-0244967-2403944", + "order_date": "2021-09-27", + "shipments": [ + { + "shipped_date": "2021-09-28", + "items": [ + { + "quantity": "1", + "description": "FC Bayern M\u00fcnchen Cuddly fleece blanket 150 x 200 cm", + "sold_by": "Offizieller FC Bayern Store", + "condition": "Neu", + "price": { + "number": "23.96", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "23.96", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "23.96", + "currency": "EUR" + }, + "errors": [] + } + ], + "credit_card_transactions": [ + { + "date": "2021-09-28", + "card_description": "Visa", + "card_ending_in": "1234", + "amount": { + "number": "23.96", + "currency": "EUR" + } + } + ], + "pretax_adjustments": [ + { + "description": "Verpackung & Versand", + "amount": { + "number": "0.00", + "currency": "EUR" + } + } + ], + "tax": null, + "posttax_adjustments": [], + "errors": [] +} \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/393-2608279-9292916.html b/testdata/source/amazon/de_DE/393-2608279-9292916.html new file mode 100644 index 00000000..79859219 --- /dev/null +++ b/testdata/source/amazon/de_DE/393-2608279-9292916.html @@ -0,0 +1,361 @@ + + + + + + +Amazon.de - Bestellung 393-2608279-9292916 + + + + +
    +
    +
    + Übersicht zur Bestellung #393-2608279-9292916 +
    +Bitte drucken Sie diese Seite aus und legen Sie sie zu Ihren Unterlagen. +

    + + + + +
    + + + + + + + +
    + + Getätigte Spar-Abo-Bestellung: + + 15. Mai 2018 +
    +Bestellnummer: + 393-2608279-9292916 +
    +Gesamtbestellwert: + EUR 0,00 +
    + + Diese Bestellung enthält Abonnieren-und-Sparen-Artikel. + +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    + versandt am 8. Juni 2018 +
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + + + + + +
    +Bestellte Artikel + +Preis +
    + + 1 + + Exemplar(e) von: + + Lavazza Caffè Decaffeinato, 2er Pack (2 x 500 g Packung)
    + + Verkauf durch: Amazon EU S.a.r.L. + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + Zustand: Neu
    +
    +
    +EUR 16,98
    +
    +
    +
    +
    + + + + + +
    + +Versandadresse + +
    + + + + + + + + + +
    +Versandart: + +
    +Standard-Versand +
    +
    + +
    +
    +
    +
    +
    + + + + +
    + + + + + + + +
    + + + + +
    +
    Zahlungsdaten
    +
    +
    + + + + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Zwischensumme:EUR 15,87
    Verpackung & Versand:EUR 0,00
     -----
    Summe ohne MwSt.:EUR 15,87
    Anzurechnende MwSt.:EUR 1,11
     -----
    Summe:EUR 16,98
    Gutschein eingelöst:-EUR 0,85
    Geschenkgutschein(e):-EUR 16,13
     -----
    Gesamtsumme: EUR 0,00
    +
    +Zahlungsart: +
    + + + Visa / Electron + | Die letzten Ziffern: 1234 +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + Geschenkgutschein
    +
    +Rechnungsadresse: + + + + + + + + + +
    +
    +
    +
    +
    +

    Um den Status Ihrer Bestellung einzusehen, kehren Sie auf Bestellungsübersicht zurück.

    +

    Hinweis: Dies ist keine Rechnung.

    +

    +
    + +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 9585-1942, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/393-2608279-9292916.json b/testdata/source/amazon/de_DE/393-2608279-9292916.json new file mode 100644 index 00000000..fbc6ad9c --- /dev/null +++ b/testdata/source/amazon/de_DE/393-2608279-9292916.json @@ -0,0 +1,72 @@ +{ + "order_id": "393-2608279-9292916", + "order_date": "2018-05-15", + "shipments": [ + { + "shipped_date": "2018-06-08", + "items": [ + { + "quantity": "1", + "description": "Lavazza Caff\u00e8 Decaffeinato, 2er Pack (2 x 500 g Packung)", + "sold_by": "Amazon EU S.a.r.L.", + "condition": "Neu", + "price": { + "number": "16.98", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "16.98", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "16.98", + "currency": "EUR" + }, + "errors": [] + } + ], + "credit_card_transactions": [ + { + "date": "2018-05-15", + "card_description": "Visa / Electron", + "card_ending_in": "1234", + "amount": { + "number": "0.00", + "currency": "EUR" + } + } + ], + "pretax_adjustments": [ + { + "description": "Verpackung & Versand", + "amount": { + "number": "0.00", + "currency": "EUR" + } + } + ], + "tax": null, + "posttax_adjustments": [ + { + "description": "Gutschein eingel\u00f6st", + "amount": { + "number": "-0.85", + "currency": "EUR" + } + }, + { + "description": "Geschenkgutschein(e)", + "amount": { + "number": "-16.13", + "currency": "EUR" + } + } + ], + "errors": [] +} \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/399-5779972-5007935.html b/testdata/source/amazon/de_DE/399-5779972-5007935.html new file mode 100644 index 00000000..c1c72f9a --- /dev/null +++ b/testdata/source/amazon/de_DE/399-5779972-5007935.html @@ -0,0 +1,316 @@ + + + + + + +Amazon.de - Bestellung 399-5779972-5007935 + + + + +
    +
    +
    + Übersicht zur Bestellung #399-5779972-5007935 +
    +Bitte drucken Sie diese Seite aus und legen Sie sie zu Ihren Unterlagen. +

    + + + + +
    + + + + +
    + + Bestellung aufgegeben am: + + 21. November 2021 +
    +Bestellnummer: + 399-5779972-5007935 +
    +Gesamtbestellwert: + EUR 16,99 +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    + versandt am 22. November 2021 +
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + + + + + +
    +Bestellte Artikel + +Preis +
    + + 1 + + Exemplar(e) von: + + tiptoi® Mein großes Wimmelbuch, Friese, Inka
    + + Verkauf durch: Amazon EU S.a.r.L. + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + Zustand: Neu
    +
    +
    +EUR 16,99
    +
    +
    +
    +
    + + + + + +
    + +Versandadresse + +
    + + + + + + + + + +
    +Versandart: + +
    +Premiumversand +
    +
    + +
    +
    +
    +
    +
    + + + + +
    + + + + + + + +
    + + + + +
    +
    Zahlungsdaten
    +
    +
    + + + + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Zwischensumme:EUR 15,88
    Verpackung & Versand:EUR 0,00
     -----
    Summe ohne MwSt.:EUR 15,88
    Anzurechnende MwSt.:EUR 1,11
     -----
    Summe:EUR 16,99
     -----
    Gesamtsumme: EUR 16,99
    +
    +Zahlungsart: +
    + + + Bankeinzug + | Die letzten Ziffern: 600
    +
    +Rechnungsadresse: + + + + + + + + + +
    +
    +
    +
    +
    +

    Um den Status Ihrer Bestellung einzusehen, kehren Sie auf Bestellungsübersicht zurück.

    +

    Hinweis: Dies ist keine Rechnung.

    +
    + +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 6622-9426, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/399-5779972-5007935.json b/testdata/source/amazon/de_DE/399-5779972-5007935.json new file mode 100644 index 00000000..e27e7f55 --- /dev/null +++ b/testdata/source/amazon/de_DE/399-5779972-5007935.json @@ -0,0 +1,57 @@ +{ + "order_id": "399-5779972-5007935", + "order_date": "2021-11-21", + "shipments": [ + { + "shipped_date": "2021-11-22", + "items": [ + { + "quantity": "1", + "description": "tiptoi\u00ae Mein gro\u00dfes Wimmelbuch, Friese, Inka", + "sold_by": "Amazon EU S.a.r.L.", + "condition": "Neu", + "price": { + "number": "16.99", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "16.99", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "16.99", + "currency": "EUR" + }, + "errors": [] + } + ], + "credit_card_transactions": [ + { + "date": "2021-11-21", + "card_description": "Bankeinzug", + "card_ending_in": "600", + "amount": { + "number": "16.99", + "currency": "EUR" + } + } + ], + "pretax_adjustments": [ + { + "description": "Verpackung & Versand", + "amount": { + "number": "0.00", + "currency": "EUR" + } + } + ], + "tax": null, + "posttax_adjustments": [], + "errors": [] +} \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/447-6209054-6766419.html b/testdata/source/amazon/de_DE/447-6209054-6766419.html new file mode 100644 index 00000000..f6186461 --- /dev/null +++ b/testdata/source/amazon/de_DE/447-6209054-6766419.html @@ -0,0 +1,221 @@ + + + + + + +Amazon.de - Bestellung 447-6209054-6766419 + + + + +
    +
    +
    + Übersicht zur Bestellung #447-6209054-6766419 +
    +Bitte drucken Sie diese Seite aus und legen Sie sie zu Ihren Unterlagen. +

    + + + + +
    + + + + + + +
    + + Bestellung aufgegeben am: + + 27. Juli 2017 +
    +Bestellnummer: + 447-6209054-6766419 + +Bestellübersicht drucken | + Rechnung drucken +
    +Gesamtbestellwert: + EUR 100,00 +
    +
    + + + + +
    + + + + + + + +
    + + + + +
    +
    Geschenkgutscheine
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + +
    +Amazon-Konto erfolgreich aufgeladen
    +
    Ihr Amazon-Konto wurde erfolgreich aufgeladen. Ihr Saldo enthält nun das zusätzliche Guthaben.
    +
    +Betrag
    +EUR 100,00
    +
    +
    +
    +
    +
    + + + + +
    + + + + + + + +
    + + + + +
    +
    Zahlungsdaten
    +
    +
    + + + + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + +
    Zwischensumme:EUR 100,00
     -----
    Summe:EUR 100,00
     -----
    Gesamtsumme: EUR 100,00
    +
    +Zahlungsart: +
    + + + + + + + +Visa / Electron + | Die letzten Ziffern:1234
    +
    +
    +
    +
    +
    +

    Um den Status Ihrer Bestellung einzusehen, kehren Sie auf Bestellungsübersicht zurück.

    +

    Hinweis: Dies ist keine Rechnung.

    +
    + +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 5286-9368, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/447-6209054-6766419.json b/testdata/source/amazon/de_DE/447-6209054-6766419.json new file mode 100644 index 00000000..1ce751c2 --- /dev/null +++ b/testdata/source/amazon/de_DE/447-6209054-6766419.json @@ -0,0 +1,49 @@ +{ + "order_id": "447-6209054-6766419", + "order_date": "2017-07-27", + "shipments": [ + { + "shipped_date": null, + "items": [ + { + "quantity": "1", + "description": "Amazon-Konto aufgeladen", + "sold_by": null, + "condition": null, + "price": { + "number": "100.00", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "100.00", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "100.00", + "currency": "EUR" + }, + "errors": [] + } + ], + "credit_card_transactions": [ + { + "date": "2017-07-27", + "card_description": "Visa / Electron", + "card_ending_in": "1234", + "amount": { + "number": "100.00", + "currency": "EUR" + } + } + ], + "pretax_adjustments": [], + "tax": null, + "posttax_adjustments": [], + "errors": [] +} \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/588-8509154-9761865.html b/testdata/source/amazon/de_DE/588-8509154-9761865.html new file mode 100644 index 00000000..70a645a2 --- /dev/null +++ b/testdata/source/amazon/de_DE/588-8509154-9761865.html @@ -0,0 +1,317 @@ + + + + + + +Amazon.de - Bestellung 588-8509154-9761865 + + + + +
    +
    +
    + Übersicht zur Bestellung #588-8509154-9761865 +
    +Bitte drucken Sie diese Seite aus und legen Sie sie zu Ihren Unterlagen. +

    + + + + +
    + + + + +
    + + Bestellung aufgegeben am: + + 19. Februar 2022 +
    +Bestellnummer: + 588-8509154-9761865 +
    +Gesamtbestellwert: + EUR 15,99 +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    + Versand wird vorbereitet +
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + + + + + +
    +Bestellte Artikel + +Preis +
    + + 1 + + Exemplar(e) von: + + CS Labs Wärmeleitpaste & Pad Ersatz-Set, K5-PRO K4-PRO kompatibel mit iPhone, Mac PS4 PS3 Xbox Asus Dell usw.
    + + Verkauf durch: WWW.COMPUTER-SYSTEMS.GR (Mitgliedsprofil) + + + + + + + + + + + + + + + + + + + + + + + + | Haben Sie eine Frage zum Produkt? Frage an den Verkäufer stellen +
    +
    + + Zustand: Neu
    +
    +
    +EUR 15,99
    +
    +
    +
    +
    + + + + + +
    + +Versandadresse + +
    + + + + + + + + + +
    +Versandart: + +
    +Premiumversand +
    +
    + +
    +
    +
    +
    +
    + + + + +
    + + + + + + + +
    + + + + +
    +
    Zahlungsdaten
    +
    +
    + + + + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Zwischensumme:EUR 13,44
    Verpackung & Versand:EUR 0,00
     -----
    Summe ohne MwSt.:EUR 13,44
    Anzurechnende MwSt.:EUR 2,55
     -----
    Summe:EUR 15,99
     -----
    Gesamtsumme: EUR 15,99
    +
    +Zahlungsart: +
    + + + Bankeinzug + | Die letzten Ziffern: 600
    +
    +Rechnungsadresse: + + + + + + + + + +
    +
    +
    +
    +
    +

    Um den Status Ihrer Bestellung einzusehen, kehren Sie auf Bestellungsübersicht zurück.

    +

    Hinweis: Dies ist keine Rechnung.

    +
    + +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 6257-8850, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/588-8509154-9761865.json b/testdata/source/amazon/de_DE/588-8509154-9761865.json new file mode 100644 index 00000000..370a8b3d --- /dev/null +++ b/testdata/source/amazon/de_DE/588-8509154-9761865.json @@ -0,0 +1,57 @@ +{ + "order_id": "588-8509154-9761865", + "order_date": "2022-02-19", + "shipments": [ + { + "shipped_date": null, + "items": [ + { + "quantity": "1", + "description": "CS Labs W\u00e4rmeleitpaste & Pad Ersatz-Set, K5-PRO K4-PRO kompatibel mit iPhone, Mac PS4 PS3 Xbox Asus Dell usw.", + "sold_by": "WWW.COMPUTER-SYSTEMS.GR", + "condition": "Neu", + "price": { + "number": "15.99", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "15.99", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "15.99", + "currency": "EUR" + }, + "errors": [] + } + ], + "credit_card_transactions": [ + { + "date": "2022-02-19", + "card_description": "Bankeinzug", + "card_ending_in": "600", + "amount": { + "number": "15.99", + "currency": "EUR" + } + } + ], + "pretax_adjustments": [ + { + "description": "Verpackung & Versand", + "amount": { + "number": "0.00", + "currency": "EUR" + } + } + ], + "tax": null, + "posttax_adjustments": [], + "errors": [] +} \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/898-5185906-0096901.html b/testdata/source/amazon/de_DE/898-5185906-0096901.html new file mode 100644 index 00000000..076bd674 --- /dev/null +++ b/testdata/source/amazon/de_DE/898-5185906-0096901.html @@ -0,0 +1,380 @@ + + + + + + +Amazon.de - Bestellung 898-5185906-0096901 + + + + +
    +
    +
    + Übersicht zur Bestellung #898-5185906-0096901 +
    +Bitte drucken Sie diese Seite aus und legen Sie sie zu Ihren Unterlagen. +

    + + + + +
    + + + + + + + +
    + + Getätigte Spar-Abo-Bestellung: + + 12. März 2018 +
    +Bestellnummer: + 898-5185906-0096901 +
    +Gesamtbestellwert: + EUR 15,75 +
    + + Diese Bestellung enthält Abonnieren-und-Sparen-Artikel. + +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    + versandt am 9. April 2018 +
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + + + + + +
    +Bestellte Artikel + +Preis +
    + + 1 + + Exemplar(e) von: + + Lavazza Caffè Decaffeinato, 2er Pack (2 x 500 g Packung)
    + + Verkauf durch: Amazon EU S.a.r.L. + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + Zustand: Neu
    +
    +
    +EUR 16,58
    +
    +
    +
    +
    + + + + + +
    + +Versandadresse + +
    + + + + + + + + + +
    +Versandart: + +
    +Standard-Versand +
    +
    + +
    +
    +
    +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    Zahlungsdaten
    +
    +
    + + + + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Zwischensumme:EUR 15,50
    Verpackung & Versand:EUR 0,00
     -----
    Summe ohne MwSt.:EUR 15,50
    Anzurechnende MwSt.:EUR 1,08
     -----
    Summe:EUR 16,58
    Gutschein eingelöst:-EUR 0,83
     -----
    Gesamtsumme: EUR 15,75
    +
    +Zahlungsart: +
    + + + Visa / Electron + | Die letzten Ziffern: 1234 +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + Geschenkgutschein
    +
    +Rechnungsadresse: + + + + + + + + + +
    +
    + + + + + +
    +
    Kreditkarten-Transaktionen 
    +
    + + + + + +
    + Visa mit den Endziffern 1234: 9. April 2018: + +EUR 15,75 +
    +
    +
    +
    +
    +
    +

    Um den Status Ihrer Bestellung einzusehen, kehren Sie auf Bestellungsübersicht zurück.

    +

    Hinweis: Dies ist keine Rechnung.

    +

    +
    + +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 8399-2848, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/898-5185906-0096901.json b/testdata/source/amazon/de_DE/898-5185906-0096901.json new file mode 100644 index 00000000..228b2f36 --- /dev/null +++ b/testdata/source/amazon/de_DE/898-5185906-0096901.json @@ -0,0 +1,65 @@ +{ + "order_id": "898-5185906-0096901", + "order_date": "2018-03-12", + "shipments": [ + { + "shipped_date": "2018-04-09", + "items": [ + { + "quantity": "1", + "description": "Lavazza Caff\u00e8 Decaffeinato, 2er Pack (2 x 500 g Packung)", + "sold_by": "Amazon EU S.a.r.L.", + "condition": "Neu", + "price": { + "number": "16.58", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "16.58", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "16.58", + "currency": "EUR" + }, + "errors": [] + } + ], + "credit_card_transactions": [ + { + "date": "2018-04-09", + "card_description": "Visa", + "card_ending_in": "1234", + "amount": { + "number": "15.75", + "currency": "EUR" + } + } + ], + "pretax_adjustments": [ + { + "description": "Verpackung & Versand", + "amount": { + "number": "0.00", + "currency": "EUR" + } + } + ], + "tax": null, + "posttax_adjustments": [ + { + "description": "Gutschein eingel\u00f6st", + "amount": { + "number": "-0.83", + "currency": "EUR" + } + } + ], + "errors": [] +} \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/974-6135682-9358749.html b/testdata/source/amazon/de_DE/974-6135682-9358749.html new file mode 100644 index 00000000..3bc8222f --- /dev/null +++ b/testdata/source/amazon/de_DE/974-6135682-9358749.html @@ -0,0 +1,586 @@ + + + + + + +Amazon.de - Bestellung 974-6135682-9358749 + + + + +
    +
    +
    + Übersicht zur Bestellung #974-6135682-9358749 +
    +Bitte drucken Sie diese Seite aus und legen Sie sie zu Ihren Unterlagen. +

    + + + + +
    + + + + +
    + + Bestellung aufgegeben am: + + 20. September 2021 +
    +Bestellnummer: + 974-6135682-9358749 +
    +Gesamtbestellwert: + EUR 33,66 +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    + versandt am 20. September 2021 +
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + + + + + +
    +Bestellte Artikel + +Preis +
    + + 1 + + Exemplar(e) von: + + Die kleine Kees de Kort-Kinderbibel (Was uns die Bibel erzählt. Neue Serie), Kees de Kort
    + + Verkauf durch: Amazon EU S.a.r.L. + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + Zustand: Neu
    +
    +
    +EUR 13,00
    +
    +
    +
    +
    + + + + + +
    + +Versandadresse + +
    + + + + + + + + + +
    +Versandart: + +
    +Premiumversand +
    +
    + +
    +
    +
    +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    + versandt am 20. September 2021 +
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + + + + + +
    +Bestellte Artikel + +Preis +
    + + 1 + + Exemplar(e) von: + + Schwungübungen Ab 3 Jahren: Übungsheft Mit Schwungübungen Zur Erhöhung Der Konzentration, Augen-Hand-Koordination Und Feinmotorik. Ideale Vorberei, Eichelberger, Laura
    + + Verkauf durch: Amazon EU S.a.r.L. + + + + + + + + + + + + +
    +
    + + Zustand: Neu
    +
    +
    +EUR 5,95
    +
    +
    +
    +
    + + + + + +
    + +Versandadresse + +
    + + + + + + + + + +
    +Versandart: + +
    +Premiumversand +
    +
    + +
    +
    +
    +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    + versandt am 20. September 2021 +
    +
    +
    + + + + +
    + + + + +
    +   +
    + + + + + + + + + +
    +Bestellte Artikel + +Preis +
    + + 1 + + Exemplar(e) von: + + Pelikan 723122 Mini Friends 755/8 Multi-Coloured Paint Box with 8 Colours and Brushes, Paint palette, multicoloured
    + + Verkauf durch: Amazon EU S.a.r.L. + + + + + + + + + + + + +
    +
    + + Zustand: Neu
    +
    +
    +EUR 14,71
    +
    +
    +
    +
    + + + + + +
    + +Versandadresse + +
    + + + + + + + + + +
    +Versandart: + +
    +Premiumversand +
    +
    + +
    +
    +
    +
    +
    + + + + +
    + + + + + + + + + + +
    + + + + +
    +
    Zahlungsdaten
    +
    +
    + + + + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Zwischensumme:EUR 30,07
    Verpackung & Versand:EUR 0,00
     -----
    Summe ohne MwSt.:EUR 30,07
    Anzurechnende MwSt.:EUR 3,59
     -----
    Summe:EUR 33,66
     -----
    Gesamtsumme: EUR 33,66
    +
    +Zahlungsart: +
    + + + Visa / Electron + | Die letzten Ziffern: 1234 +
    +
    +Rechnungsadresse: + + + + + + + + + +
    +
    + + + + + +
    +
    Kreditkarten-Transaktionen 
    +
    + + + + + + + + + + + + + +
    + Visa mit den Endziffern 1234: 20. September 2021: + +EUR 5,95 +
    + Visa mit den Endziffern 1234: 20. September 2021: + +EUR 13,00 +
    + Visa mit den Endziffern 1234: 20. September 2021: + +EUR 14,71 +
    +
    +
    +
    +
    +
    +

    Um den Status Ihrer Bestellung einzusehen, kehren Sie auf Bestellungsübersicht zurück.

    +

    Hinweis: Dies ist keine Rechnung.

    +
    + +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 0470-8920, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/974-6135682-9358749.json b/testdata/source/amazon/de_DE/974-6135682-9358749.json new file mode 100644 index 00000000..23e63ce1 --- /dev/null +++ b/testdata/source/amazon/de_DE/974-6135682-9358749.json @@ -0,0 +1,131 @@ +{ + "order_id": "974-6135682-9358749", + "order_date": "2021-09-20", + "shipments": [ + { + "shipped_date": "2021-09-20", + "items": [ + { + "quantity": "1", + "description": "Die kleine Kees de Kort-Kinderbibel (Was uns die Bibel erz\u00e4hlt. Neue Serie), Kees de Kort", + "sold_by": "Amazon EU S.a.r.L.", + "condition": "Neu", + "price": { + "number": "13.00", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "13.00", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "13.00", + "currency": "EUR" + }, + "errors": [] + }, + { + "shipped_date": "2021-09-20", + "items": [ + { + "quantity": "1", + "description": "Schwung\u00fcbungen Ab 3 Jahren: \u00dcbungsheft Mit Schwung\u00fcbungen Zur Erh\u00f6hung Der Konzentration, Augen-Hand-Koordination Und Feinmotorik. Ideale Vorberei, Eichelberger, Laura", + "sold_by": "Amazon EU S.a.r.L.", + "condition": "Neu", + "price": { + "number": "5.95", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "5.95", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "5.95", + "currency": "EUR" + }, + "errors": [] + }, + { + "shipped_date": "2021-09-20", + "items": [ + { + "quantity": "1", + "description": "Pelikan 723122 Mini Friends 755/8 Multi-Coloured Paint Box with 8 Colours and Brushes, Paint palette, multicoloured", + "sold_by": "Amazon EU S.a.r.L.", + "condition": "Neu", + "price": { + "number": "14.71", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": { + "number": "14.71", + "currency": "EUR" + }, + "posttax_adjustments": [], + "tax": [], + "total": { + "number": "14.71", + "currency": "EUR" + }, + "errors": [] + } + ], + "credit_card_transactions": [ + { + "date": "2021-09-20", + "card_description": "Visa", + "card_ending_in": "1234", + "amount": { + "number": "5.95", + "currency": "EUR" + } + }, + { + "date": "2021-09-20", + "card_description": "Visa", + "card_ending_in": "1234", + "amount": { + "number": "13.00", + "currency": "EUR" + } + }, + { + "date": "2021-09-20", + "card_description": "Visa", + "card_ending_in": "1234", + "amount": { + "number": "14.71", + "currency": "EUR" + } + } + ], + "pretax_adjustments": [ + { + "description": "Verpackung & Versand", + "amount": { + "number": "0.00", + "currency": "EUR" + } + } + ], + "tax": null, + "posttax_adjustments": [], + "errors": [] +} \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/D22-9220967-2566135.html b/testdata/source/amazon/de_DE/D22-9220967-2566135.html new file mode 100644 index 00000000..0bf5ca35 --- /dev/null +++ b/testdata/source/amazon/de_DE/D22-9220967-2566135.html @@ -0,0 +1,233 @@ + + + + +Amazon.de: Übersicht - Digitale Bestellung + + + + + +
    + + + +

    +
    + + Details zur Bestellung # D22-9220967-2566135 + +
    +Drucken Sie diese Seite für Ihre Unterlagen aus. + +
    +
    +
    + + + + + + + + +
    +Amazon.de Bestellnummer: D22-9220967-2566135 +
    +Summe der Bestellung: EUR 9,95 +

    + + + + + + + +
    +Digitale Bestellung: 31 Mai 2020 +
    + + + + +
    + + + + + + + + +
    +Bestellte Artikel
    +
    +Preis +
    +Audible Flexi-Abo [Digitales Abo][Hörbuch]
    +
    +Verkauft von: Audible

    EUR 9,95
    + + + Zwischensumme Artikel: EUR 9,30
    + + + + + ----
    + + + + + +Gesamtbetrag: EUR 9,30
    MwSt: EUR 0,65
    + ----
    +Gesamtbetrag für diese Bestellung: EUR 9,95
    +
    +
    +
    +
    +
    + + + + + + + +
    +Zahlungsinformation +
    + + + + +
    + + +
    Zahlungsarten
    • endet im 3044
    Rechnungsadresse
    Zwischensumme Artikel:
    EUR 9,30

    Gesamtbetrag:
    EUR 9,30
    MwSt:
    EUR 0,65


    Endsumme:
    EUR 9,95
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    +

    + Zurück zu Bestellungsübersicht. +

    +

    Anmerkung: Dies ist keine Rechnung mit Mehrwertsteuer.

    +

    +

    +
    +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 2841-0341, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/D22-9220967-2566135.json b/testdata/source/amazon/de_DE/D22-9220967-2566135.json new file mode 100644 index 00000000..5c7a6da1 --- /dev/null +++ b/testdata/source/amazon/de_DE/D22-9220967-2566135.json @@ -0,0 +1,35 @@ +{ + "order_id": "D22-9220967-2566135", + "order_date": "2020-05-31", + "shipments": [ + { + "shipped_date": "2020-05-31", + "items": [ + { + "description": "Audible Flexi-Abo [Digitales Abo]", + "url": "https://www.amazon.de/dp/B08H5XW8SJ/ref=docs-os-doi_0", + "sold_by": "Audible", + "by": null, + "price": { + "number": "9.95", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": null, + "posttax_adjustments": [], + "tax": [], + "total": null, + "errors": [ + "expected total is 0.65 EUR, but parsed value is None" + ] + } + ], + "credit_card_transactions": [], + "pretax_adjustments": [], + "tax": null, + "posttax_adjustments": [], + "errors": [] +} \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/D60-9825125-4795642.html b/testdata/source/amazon/de_DE/D60-9825125-4795642.html new file mode 100644 index 00000000..7eadad54 --- /dev/null +++ b/testdata/source/amazon/de_DE/D60-9825125-4795642.html @@ -0,0 +1,233 @@ + + + + +Amazon.de: Übersicht - Digitale Bestellung + + + + + +
    + + + +

    +
    + + Details zur Bestellung # D60-9825125-4795642 + +
    +Drucken Sie diese Seite für Ihre Unterlagen aus. + +
    +
    +
    + + + + + + + + +
    +Amazon.de Bestellnummer: D60-9825125-4795642 +
    +Summe der Bestellung: EUR 4,98 +

    + + + + + + + +
    +Digitale Bestellung: 22 Dezember 2019 +
    + + + + +
    + + + + + + + + +
    +Bestellte Artikel
    +
    +Preis +
    +Das erstaunliche Leben des Walter Mitty [dt./OV][Prime Video]
    Von: Ben Stiller, Kristen Wiig, Jon Daly
    +
    +Verkauft von: Amazon Digital Germany GmbH

    EUR 4,98
    + + + Zwischensumme Artikel: EUR 4,18
    + + + + + ----
    + + + + + +Gesamtbetrag: EUR 4,18
    MwSt: EUR 0,80
    + ----
    +Gesamtbetrag für diese Bestellung: EUR 4,98
    +
    +
    +
    +
    +
    + + + + + + + +
    +Zahlungsinformation +
    + + + + +
    + + +
    Zahlungsarten
    • endet im 3044
    Rechnungsadresse
    Zwischensumme Artikel:
    EUR 4,18

    Gesamtbetrag:
    EUR 4,18
    MwSt:
    EUR 0,80


    Endsumme:
    EUR 4,98
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    +

    + Zurück zu Bestellungsübersicht. +

    +

    Anmerkung: Dies ist keine Rechnung mit Mehrwertsteuer.

    +

    +

    +
    +
    + + + +
    + +Unsere AGB |  + + + + + + +Datenschutzerklärung |  + + + + + + +Impressum  © 4153-1084, Amazon.com, Inc. und Tochtergesellschaften +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    \ No newline at end of file diff --git a/testdata/source/amazon/de_DE/D60-9825125-4795642.json b/testdata/source/amazon/de_DE/D60-9825125-4795642.json new file mode 100644 index 00000000..82072efa --- /dev/null +++ b/testdata/source/amazon/de_DE/D60-9825125-4795642.json @@ -0,0 +1,35 @@ +{ + "order_id": "D60-9825125-4795642", + "order_date": "2019-12-22", + "shipments": [ + { + "shipped_date": "2019-12-22", + "items": [ + { + "description": "Das erstaunliche Leben des Walter Mitty [dt./OV]", + "url": "https://www.amazon.de/dp/B00JZPPGNC/ref=docs-os-doi_0", + "sold_by": "Amazon Digital Germany GmbH", + "by": "Ben Stiller, Kristen Wiig, Jon Daly", + "price": { + "number": "4.98", + "currency": "EUR" + } + } + ], + "items_subtotal": null, + "pretax_adjustments": [], + "total_before_tax": null, + "posttax_adjustments": [], + "tax": [], + "total": null, + "errors": [ + "expected total is 0.80 EUR, but parsed value is None" + ] + } + ], + "credit_card_transactions": [], + "pretax_adjustments": [], + "tax": null, + "posttax_adjustments": [], + "errors": [] +} \ No newline at end of file