├── .builds ├── py311-alpine.yml ├── py312-alpine.yml └── py313-docker.yml ├── .envrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── question.md └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.rst ├── LICENCE ├── README.rst ├── django_afip ├── __init__.py ├── admin.py ├── apps.py ├── clients.py ├── crypto.py ├── exceptions.py ├── factories.py ├── fixtures │ ├── clientvatcondition.yaml │ ├── concepttype.yaml │ ├── currencytype.yaml │ ├── documenttype.yaml │ ├── optionaltype.yaml │ ├── receipttype.yaml │ ├── taxtype.yaml │ └── vattype.yaml ├── helpers.py ├── locale │ └── es │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── management │ └── commands │ │ ├── __init__.py │ │ └── afipmetadata.py ├── migrations │ ├── 0001_squashed_0036_receiptpdf__client_address__blank.py │ ├── 0002_taxpayerextras.py │ ├── 0003_issuance_type_length.py │ ├── 0004_storages_and_help_texts.py │ ├── 0005_flatten_taxpayer_extras.py │ ├── 0006_delete_taxpayerextras.py │ ├── 0007_auto_20210409_1641.py │ ├── 0008_move_taxpayerprofile_to_pos.py │ ├── 0009_alter_pointofsales_issuance_type.py │ ├── 0010_alter_authticket_service.py │ ├── 0011_receiptentry_discount_and_more.py │ ├── 0012_optionaltype_optional_alter_code_in_generics.py │ ├── 0013_alter_receiptentry_quantity.py │ ├── 0014_alter_pointofsales_blocked_alter_taxpayer_logo.py │ ├── 0015_alter_taxpayer_logo.py │ ├── 0016_clientvatcondition_receipt_client_vat_condition.py │ └── __init__.py ├── models.py ├── parsers.py ├── pdf.py ├── py.typed ├── serializers.py ├── signals.py ├── static │ └── receipts │ │ └── receipt.css ├── templates │ └── receipts │ │ ├── code_11.html │ │ ├── code_13.html │ │ ├── code_3.html │ │ ├── code_6.html │ │ └── code_8.html ├── templatetags │ ├── __init__.py │ └── django_afip.py ├── testing │ ├── __init__.py │ ├── test.crt │ ├── test.key │ ├── test2.crt │ ├── test2.key │ ├── test_expired.crt │ ├── test_expired.key │ └── tiny.png └── views.py ├── docs ├── Makefile ├── api.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── index.rst ├── installation.rst ├── printables.rst ├── security.rst └── usage.rst ├── pyproject.toml ├── scripts └── dump_metadata.py ├── testapp ├── __init__.py ├── manage.py ├── settings.py ├── test_helpers.py ├── testmain │ ├── __init__.py │ ├── templates │ │ └── receipts │ │ │ └── 20329642330 │ │ │ └── pos_9999 │ │ │ └── code_6.html │ └── views.py ├── urls.py └── wsgi.py ├── tests ├── __init__.py ├── conftest.py ├── signed_data.bin ├── test_admin.py ├── test_clients.py ├── test_crypto.py ├── test_gentestkey.py ├── test_management.py ├── test_migrations.py ├── test_models.py ├── test_parsers.py ├── test_pdf.py ├── test_tags.py ├── test_taxpayer.py ├── test_transaction_protection.py ├── test_vattype.py ├── test_views.py └── test_webservices.py └── tox.ini /.builds/py311-alpine.yml: -------------------------------------------------------------------------------- 1 | image: alpine/3.19 2 | packages: 3 | - alpine-sdk 4 | - font-dejavu # makedepends for wheels 5 | - ghostscript # makedepends for wheels 6 | - libpq-dev # makedepends for wheels 7 | - mariadb # server itself 8 | - mariadb-dev # lib for wheels 9 | - mariadb-openrc 10 | - pango # makedepends for wheels 11 | - postgresql 12 | - postgresql-common-openrc # tests 13 | - py3-tox 14 | - python3-dev 15 | sources: 16 | - https://github.com/whyNotHugo/django-afip 17 | environment: 18 | CI: true 19 | tasks: 20 | - postgres: | 21 | sudo service postgresql start 22 | - mariadb: | 23 | sudo sed -i 's/skip-networking/skip-grant-tables/' /etc/my.cnf.d/mariadb-server.cnf 24 | sudo /etc/init.d/mariadb setup 25 | sudo service mariadb start 26 | - tox-sqlite: | 27 | cd django-afip 28 | tox -e sqlite 29 | - tox-django42-mariadb: | 30 | cd django-afip 31 | tox -e django42-mysql 32 | - tox-django50-mariadb: | 33 | cd django-afip 34 | tox -e django50-mysql 35 | - tox-django42-postgres: | 36 | cd django-afip 37 | tox -e django42-postgres 38 | - tox-django50-postgres: | 39 | cd django-afip 40 | tox -e django50-postgres 41 | - tox-django51-postgres: | 42 | cd django-afip 43 | tox -e django51-postgres 44 | - lint: | 45 | cd django-afip 46 | tox -e mypy,ruff,vermin 47 | - tox-docs: | 48 | cd django-afip 49 | tox -e docs 50 | -------------------------------------------------------------------------------- /.builds/py312-alpine.yml: -------------------------------------------------------------------------------- 1 | image: alpine/3.20 2 | packages: 3 | - alpine-sdk 4 | - font-dejavu # makedepends for wheels 5 | - ghostscript # makedepends for wheels 6 | - libpq-dev # makedepends for wheels 7 | - mariadb # server itself 8 | - mariadb-dev # lib for wheels 9 | - mariadb-openrc 10 | - pango # makedepends for wheels 11 | - postgresql 12 | - postgresql-common-openrc # tests 13 | - py3-tox 14 | - python3-dev 15 | sources: 16 | - https://github.com/whyNotHugo/django-afip 17 | environment: 18 | CI: true 19 | tasks: 20 | - postgres: | 21 | sudo service postgresql start 22 | - mariadb: | 23 | sudo sed -i 's/skip-networking/skip-grant-tables/' /etc/my.cnf.d/mariadb-server.cnf 24 | sudo /etc/init.d/mariadb setup 25 | sudo service mariadb start 26 | - tox-sqlite: | 27 | cd django-afip 28 | tox -e sqlite 29 | - tox-django42-mariadb: | 30 | cd django-afip 31 | tox -e django42-mysql 32 | - tox-django50-mariadb: | 33 | cd django-afip 34 | tox -e django50-mysql 35 | - tox-django42-postgres: | 36 | cd django-afip 37 | tox -e django42-postgres 38 | - tox-django50-postgres: | 39 | cd django-afip 40 | tox -e django50-postgres 41 | - tox-django51-postgres: | 42 | cd django-afip 43 | tox -e django51-postgres 44 | - lint: | 45 | cd django-afip 46 | tox -e mypy,ruff,vermin 47 | - tox-docs: | 48 | cd django-afip 49 | tox -e docs 50 | 51 | - tox-live: | 52 | cd django-afip 53 | tox -e live 54 | -------------------------------------------------------------------------------- /.builds/py313-docker.yml: -------------------------------------------------------------------------------- 1 | image: alpine/3.21 2 | packages: 3 | - alpine-sdk 4 | - docker 5 | sources: 6 | - https://github.com/whyNotHugo/django-afip 7 | environment: 8 | CI: true 9 | tasks: 10 | - docker: | 11 | sudo service docker start 12 | sudo addgroup $(whoami) docker 13 | - tests: | 14 | cd django-afip 15 | docker run --rm -i -v $(pwd):/src -w /src python:3.13-alpine /bin/sh -e <- 21 | --health-cmd pg_isready 22 | --health-interval 10s 23 | --health-timeout 5s 24 | --health-retries 5 25 | ports: 26 | - 5432:5432 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | # 3.11 is tested via /.builds/alpine-py311.yml 31 | # TODO: move these oldest pythons to sr.ht job in docker 32 | python: ["3.9", "3.10"] 33 | name: postgres, python${{ matrix.python }} 34 | steps: 35 | - uses: actions/checkout@v3 36 | - uses: actions/setup-python@v4 37 | with: 38 | python-version: ${{ matrix.python }} 39 | - run: pip install tox 40 | - run: sudo apt-get update && sudo apt-get install libpq-dev 41 | - run: tox -e py-postgres 42 | mysql: 43 | runs-on: ubuntu-latest 44 | services: 45 | mysql: 46 | image: mariadb 47 | env: 48 | MARIADB_PASSWORD: mysql 49 | MARIADB_ROOT_PASSWORD: mysql 50 | options: >- 51 | --health-cmd="healthcheck.sh --connect --innodb_initialized" 52 | --health-interval=5s 53 | --health-timeout=2s 54 | --health-retries=3 55 | ports: 56 | - 3306:3306 57 | strategy: 58 | fail-fast: false 59 | name: mysql 60 | steps: 61 | - uses: actions/checkout@v3 62 | - uses: actions/setup-python@v4 63 | with: 64 | python-version: 3.9 65 | - run: pip install tox 66 | - run: sudo apt-get update && sudo apt-get install libmysqlclient-dev 67 | - run: tox -e py-mysql 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Development files 2 | *.pyc 3 | .*.sw? 4 | 5 | # Build-related files 6 | /django_afip.egg-info 7 | /build 8 | /dist 9 | /.eggs 10 | docs/_build/ 11 | django_afip/version.py 12 | 13 | # Test-related files 14 | *.sqlite3 15 | /testapp/media 16 | .tox 17 | .coverage 18 | testapp/test_ticket.yaml 19 | test.csr 20 | test2.csr 21 | 22 | # Created by tests: 23 | /media/ 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | args: [--markdown-linebreak-ext=md] 7 | - id: end-of-file-fixer 8 | - id: check-toml 9 | - id: check-added-large-files 10 | - id: debug-statements 11 | - repo: local 12 | hooks: 13 | - id: mypy 14 | name: mypy 15 | language: system 16 | entry: mypy 17 | types_or: [python, pyi] 18 | - id: ruff 19 | name: ruff 20 | language: system 21 | entry: tox -e ruff 22 | types_or: [python, pyi] 23 | pass_filenames: false 24 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | build: 7 | os: "ubuntu-22.04" 8 | tools: 9 | python: "3.9" 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | extra_requirements: 16 | - docs 17 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | docs/changelog.rst -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2023, Hugo Osvaldo Barrera 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 9 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-afip 2 | =========== 3 | 4 | What's this? 5 | ------------ 6 | 7 | AFIP is Argentina's tax collection agency. For emitting invoices, taxpayers are 8 | required to inform AFIP of each invoice by using a dedicated SOAP-like web 9 | service for that. 10 | 11 | **django-afip** is a Django application implementing the necessary bits for 12 | Django-based applications to both store and comunícate invoicing information. 13 | 14 | Features 15 | -------- 16 | 17 | * Validate invoices and other receipt types with AFIP's WSFE service. 18 | * Generate valid PDF files for those receipts to send to clients. 19 | 20 | Documentation 21 | ------------- 22 | 23 | The full documentation is available at https://django-afip.readthedocs.io/ 24 | 25 | It is also possible to build the documentation from source using: 26 | 27 | .. code-block:: sh 28 | 29 | tox -e docs 30 | 31 | Changelog 32 | --------- 33 | 34 | The changelog is included with the rest of the documentation: 35 | https://django-afip.readthedocs.io/en/stable/changelog.html 36 | 37 | Donate 38 | ------ 39 | 40 | While some of the work done here is done for clients, much of it is done in my 41 | free time. If you appreciate the work done here, please consider donating_. 42 | 43 | .. _donating: https://whynothugo.nl/sponsor/ 44 | 45 | Licence 46 | ------- 47 | 48 | This software is distributed under the ISC licence. See LICENCE for details. 49 | 50 | Copyright (c) 2015-2023 Hugo Osvaldo Barrera 51 | -------------------------------------------------------------------------------- /django_afip/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import version # type: ignore # noqa: PGH003 4 | 5 | __version__ = version.version 6 | -------------------------------------------------------------------------------- /django_afip/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class AfipConfig(AppConfig): 7 | name = "django_afip" 8 | label = "afip" 9 | verbose_name = "AFIP" 10 | default_auto_field = "django.db.models.AutoField" 11 | 12 | def ready(self) -> None: 13 | # Register app signals: 14 | from django_afip import signals # noqa: F401 15 | -------------------------------------------------------------------------------- /django_afip/clients.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import lru_cache 4 | from typing import TYPE_CHECKING 5 | from urllib.parse import urlparse 6 | 7 | from requests import Session 8 | from requests.adapters import HTTPAdapter 9 | from urllib3.util.ssl_ import create_urllib3_context 10 | from zeep import Client 11 | from zeep.cache import SqliteCache 12 | from zeep.transports import Transport 13 | 14 | if TYPE_CHECKING: 15 | from urllib3 import PoolManager 16 | from urllib3 import ProxyManager 17 | 18 | __all__ = ("get_client",) 19 | 20 | try: 21 | from zoneinfo import ZoneInfo 22 | except ImportError: 23 | from backports.zoneinfo import ZoneInfo # type: ignore[no-redef] 24 | 25 | TZ_AR = ZoneInfo("America/Argentina/Buenos_Aires") 26 | 27 | # Each boolean field is True if the URL is a sandbox/testing URL. 28 | WSDLS = { 29 | "production": { 30 | "wsaa": "https://wsaa.afip.gov.ar/ws/services/LoginCms?wsdl", 31 | "wsfe": "https://servicios1.afip.gov.ar/wsfev1/service.asmx?WSDL", 32 | "ws_sr_constancia_inscripcion": "https://aws.afip.gov.ar/sr-padron/webservices/personaServiceA5?WSDL", 33 | "ws_sr_padron_a13": "https://aws.afip.gov.ar/sr-padron/webservices/personaServiceA13?WSDL", 34 | }, 35 | "sandbox": { 36 | "wsaa": "https://wsaahomo.afip.gov.ar/ws/services/LoginCms?wsdl", 37 | "wsfe": "https://wswhomo.afip.gov.ar/wsfev1/service.asmx?WSDL", 38 | "ws_sr_constancia_inscripcion": "https://awshomo.afip.gov.ar/sr-padron/webservices/personaServiceA5?WSDL", 39 | "ws_sr_padron_a13": "https://awshomo.afip.gov.ar/sr-padron/webservices/personaServiceA13?WSDL", 40 | }, 41 | } 42 | 43 | 44 | class AFIPAdapter(HTTPAdapter): 45 | """An adapter with reduced security so it'll work with AFIP.""" 46 | 47 | def init_poolmanager(self, *args, **kwargs) -> PoolManager: 48 | context = create_urllib3_context(ciphers="AES128-SHA") 49 | context.load_default_certs() 50 | kwargs["ssl_context"] = context 51 | return super().init_poolmanager(*args, **kwargs) 52 | 53 | def proxy_manager_for(self, *args, **kwargs) -> ProxyManager: 54 | context = create_urllib3_context(ciphers="AES128-SHA") 55 | context.load_default_certs() 56 | kwargs["ssl_context"] = context 57 | return super().proxy_manager_for(*args, **kwargs) 58 | 59 | 60 | @lru_cache(maxsize=1) 61 | def get_or_create_transport() -> Transport: 62 | """Create a specially-configured Zeep transport. 63 | 64 | This transport does two non-default things: 65 | - Reduces TLS security. Sadly, AFIP only has insecure endpoints, so we're 66 | forced to reduce security to talk to them. 67 | - Cache the WSDL file for a whole day. 68 | 69 | This function will only create a transport once, and return the same 70 | transport in subsequent calls. 71 | """ 72 | 73 | session = Session() 74 | 75 | # For each WSDL, extract the domain, and add it as an exception: 76 | for environment in WSDLS.values(): 77 | for url in environment.values(): 78 | parsed = urlparse(url) 79 | base_url = f"{parsed.scheme}://{parsed.netloc}" 80 | session.mount(base_url, AFIPAdapter()) 81 | 82 | return Transport(cache=SqliteCache(timeout=86400), session=session) 83 | 84 | 85 | @lru_cache(maxsize=32) 86 | def get_client(service_name: str, sandbox: bool = False) -> Client: 87 | """ 88 | Return a client for a given service. 89 | 90 | The `sandbox` argument should only be necessary if the client will be 91 | used to make a request. If it will only be used to serialize objects, it is 92 | irrelevant. A caller can avoid the overhead of determining the sandbox mode in the 93 | calling context if only serialization operations will take place. 94 | 95 | This function is cached with `lru_cache`, and will re-use existing clients 96 | if possible. 97 | 98 | :param service_name: The name of the web services. 99 | :param sandbox: Whether the sandbox (or production) environment should 100 | be used by the returned client. 101 | :returns: A zeep client to communicate with an AFIP web service. 102 | """ 103 | environment = "sandbox" if sandbox else "production" 104 | key = service_name.lower() 105 | 106 | try: 107 | return Client(WSDLS[environment][key], transport=get_or_create_transport()) 108 | except KeyError: 109 | raise ValueError(f"Unknown service name, {service_name}") from None 110 | -------------------------------------------------------------------------------- /django_afip/crypto.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import IO 4 | 5 | from cryptography.hazmat.primitives import hashes 6 | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey 7 | from cryptography.hazmat.primitives.serialization import Encoding 8 | from cryptography.hazmat.primitives.serialization import load_pem_private_key 9 | from cryptography.hazmat.primitives.serialization.pkcs7 import PKCS7Options 10 | from cryptography.hazmat.primitives.serialization.pkcs7 import PKCS7SignatureBuilder 11 | from cryptography.x509 import load_pem_x509_certificate 12 | from OpenSSL import crypto 13 | 14 | from django_afip import exceptions 15 | 16 | 17 | def create_embeded_pkcs7_signature(data: bytes, cert: bytes, key: bytes) -> bytes: 18 | """Creates an embedded ("nodetached") PKCS7 signature. 19 | 20 | This is equivalent to the output of:: 21 | 22 | openssl smime -sign -signer cert -inkey key -outform DER -nodetach < data 23 | """ 24 | 25 | try: 26 | pkey = load_pem_private_key(key, None) 27 | signcert = load_pem_x509_certificate(cert) 28 | except Exception as e: 29 | raise exceptions.CorruptCertificate from e 30 | 31 | if not isinstance(pkey, RSAPrivateKey): 32 | raise exceptions.CorruptCertificate("Private key is not RSA") 33 | 34 | return ( 35 | PKCS7SignatureBuilder() 36 | .set_data(data) 37 | .add_signer(signcert, pkey, hashes.SHA256()) 38 | .sign(Encoding.DER, [PKCS7Options.Binary]) 39 | ) 40 | 41 | 42 | def create_key(file_: IO[bytes]) -> None: 43 | """Create a key and write it into ``file_``.""" 44 | pkey = crypto.PKey() 45 | pkey.generate_key(crypto.TYPE_RSA, 2048) 46 | 47 | file_.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey)) 48 | file_.flush() 49 | 50 | 51 | def create_csr( 52 | key_file: IO[bytes], 53 | organization_name: str, 54 | common_name: str, 55 | serial_number: str, 56 | file_: IO[bytes], 57 | ) -> None: 58 | """Create a certificate signing request and write it into ``file_``.""" 59 | key = crypto.load_privatekey(crypto.FILETYPE_PEM, key_file.read()) 60 | 61 | req = crypto.X509Req() 62 | subj = req.get_subject() 63 | 64 | subj.O = organization_name 65 | subj.CN = common_name 66 | subj.serialNumber = serial_number # type: ignore[attr-defined] 67 | 68 | req.set_pubkey(key) 69 | req.sign(key, "md5") 70 | 71 | file_.write(crypto.dump_certificate_request(crypto.FILETYPE_PEM, req)) 72 | -------------------------------------------------------------------------------- /django_afip/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class DjangoAfipException(Exception): 5 | """Superclass for exceptions thrown by `django_afip`.""" 6 | 7 | 8 | class AfipException(DjangoAfipException): 9 | """Wraps around errors returned by AFIP's WS.""" 10 | 11 | def __init__(self, response) -> None: # noqa: ANN001 12 | if "Errors" in response: 13 | message = ( 14 | f"Error {response.Errors.Err[0].Code}: {response.Errors.Err[0].Msg}" 15 | ) 16 | else: 17 | message = ( 18 | f"Error {response.errorConstancia.idPersona}: " 19 | f"{response.errorConstancia.error[0]}" 20 | ) 21 | Exception.__init__(self, message) 22 | 23 | 24 | class AuthenticationError(DjangoAfipException): 25 | """Raised when there is an error during an authentication attempt. 26 | 27 | Usually, subclasses of this error are raised, but for unexpected errors, this type 28 | may be raised. 29 | """ 30 | 31 | 32 | class CertificateExpired(AuthenticationError): 33 | """Raised when an authentication was attempted with an expired certificate.""" 34 | 35 | 36 | class UntrustedCertificate(AuthenticationError): 37 | """Raise when an untrusted certificate is used in an authentication attempt.""" 38 | 39 | 40 | class CorruptCertificate(AuthenticationError): 41 | """Raised when a corrupt certificate file is used in an authentication attempt.""" 42 | 43 | 44 | class CannotValidateTogether(DjangoAfipException): 45 | """Raised when attempting to validate invalid combinations of receipts. 46 | 47 | Receipts of different ``receipt_type`` or ``point_of_sales`` cannot be validated 48 | together. 49 | """ 50 | 51 | 52 | class ValidationError(DjangoAfipException): 53 | """Raised when a single Receipt failed to validate with AFIP's WS.""" 54 | -------------------------------------------------------------------------------- /django_afip/factories.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import date 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | from django.contrib.auth.models import User 8 | from django.utils.timezone import make_aware 9 | from factory import LazyFunction 10 | from factory import PostGenerationMethodCall 11 | from factory import Sequence 12 | from factory import SubFactory 13 | from factory import post_generation 14 | from factory.django import DjangoModelFactory 15 | from factory.django import FileField 16 | from factory.django import ImageField 17 | 18 | from django_afip import models 19 | 20 | 21 | def get_test_file(filename: str, mode: str = "r") -> Path: 22 | """Helper to get test files.""" 23 | return Path(__file__).parent / "testing" / filename 24 | 25 | 26 | class UserFactory(DjangoModelFactory): 27 | class Meta: 28 | model = User 29 | 30 | username = "john doe" 31 | email = "john@doe.co" 32 | password = PostGenerationMethodCall("set_password", "123") 33 | 34 | 35 | class SuperUserFactory(UserFactory): 36 | is_staff = True 37 | is_superuser = True 38 | 39 | 40 | class ConceptTypeFactory(DjangoModelFactory): 41 | class Meta: 42 | model = models.ConceptType 43 | django_get_or_create = ("code",) 44 | 45 | code = "1" 46 | description = "Producto" 47 | valid_from = date(2010, 9, 17) 48 | 49 | 50 | class DocumentTypeFactory(DjangoModelFactory): 51 | class Meta: 52 | model = models.DocumentType 53 | 54 | code = "96" 55 | description = "DNI" 56 | valid_from = date(2008, 7, 25) 57 | 58 | 59 | class CurrencyTypeFactory(DjangoModelFactory): 60 | class Meta: 61 | model = models.CurrencyType 62 | 63 | code = "PES" 64 | description = "Pesos Argentinos" 65 | valid_from = date(2009, 4, 3) 66 | 67 | 68 | class ReceiptTypeFactory(DjangoModelFactory): 69 | class Meta: 70 | model = models.ReceiptType 71 | django_get_or_create = ("code",) 72 | 73 | code = "11" 74 | description = "Factura C" 75 | valid_from = date(2011, 3, 30) 76 | 77 | 78 | class TaxPayerFactory(DjangoModelFactory): 79 | class Meta: 80 | model = models.TaxPayer 81 | django_get_or_create = ("cuit",) 82 | 83 | name = "John Smith" 84 | cuit = 20329642330 85 | is_sandboxed = True 86 | key = FileField(from_path=get_test_file("test.key")) 87 | certificate = FileField(from_path=get_test_file("test.crt")) 88 | active_since = datetime(2011, 10, 3) 89 | logo = ImageField(from_path=get_test_file("tiny.png", "rb")) 90 | 91 | 92 | class AlternateTaxpayerFactory(DjangoModelFactory): 93 | """A taxpayer with an alternate (valid) keypair.""" 94 | 95 | class Meta: 96 | model = models.TaxPayer 97 | 98 | name = "John Smith" 99 | cuit = 20329642330 100 | is_sandboxed = True 101 | key = FileField(from_path=get_test_file("test2.key")) 102 | certificate = FileField(from_path=get_test_file("test2.crt")) 103 | active_since = datetime(2011, 10, 3) 104 | 105 | 106 | class PointOfSalesFactory(DjangoModelFactory): 107 | class Meta: 108 | model = models.PointOfSales 109 | django_get_or_create = ( 110 | "owner", 111 | "number", 112 | ) 113 | 114 | number = 1 115 | issuance_type = "CAE" 116 | blocked = False 117 | owner = SubFactory(TaxPayerFactory) 118 | # TODO: Renamethis to something more regional: 119 | issuing_name = "Red Company Inc." 120 | issuing_address = "100 Red Av\nRedsville\nUK" 121 | issuing_email = "billing@example.com" 122 | vat_condition = "Exempt" 123 | gross_income_condition = "Exempt" 124 | sales_terms = "Credit Card" 125 | 126 | 127 | class ReceiptFactory(DjangoModelFactory): 128 | class Meta: 129 | model = models.Receipt 130 | 131 | concept = SubFactory(ConceptTypeFactory, code=1) 132 | document_type = SubFactory(DocumentTypeFactory, code=96) 133 | document_number = 203012345 134 | issued_date = LazyFunction(date.today) 135 | total_amount = 130 136 | net_untaxed = 0 137 | net_taxed = 100 138 | exempt_amount = 0 139 | currency = SubFactory(CurrencyTypeFactory, code="PES") 140 | currency_quote = 1 141 | receipt_type = SubFactory(ReceiptTypeFactory, code=6) 142 | point_of_sales = SubFactory(PointOfSalesFactory) 143 | 144 | 145 | class ReceiptWithVatAndTaxFactory(ReceiptFactory): 146 | """Receipt with a valid Vat and Tax, ready to validate.""" 147 | 148 | point_of_sales = LazyFunction(lambda: models.PointOfSales.objects.first()) 149 | 150 | @post_generation 151 | def post(obj: models.Receipt, create: bool, extracted: None, **kwargs) -> None: 152 | VatFactory(vat_type__code=5, receipt=obj) 153 | TaxFactory(tax_type__code=3, receipt=obj) 154 | 155 | 156 | class ReceiptFCEAWithVatAndTaxFactory(ReceiptFactory): 157 | """Receipt FCEA with a valid Vat and Tax, ready to validate.""" 158 | 159 | document_number = 20111111112 160 | point_of_sales = LazyFunction(lambda: models.PointOfSales.objects.first()) 161 | receipt_type = SubFactory(ReceiptTypeFactory, code=201) 162 | document_type = SubFactory(DocumentTypeFactory, code=80) 163 | expiration_date = LazyFunction(date.today) 164 | 165 | @post_generation 166 | def post(obj: models.Receipt, create: bool, extracted: None, **kwargs) -> None: 167 | VatFactory(vat_type__code=5, receipt=obj) 168 | TaxFactory(tax_type__code=3, receipt=obj) 169 | 170 | 171 | class ReceiptFCEAWithVatTaxAndOptionalsFactory(ReceiptFCEAWithVatAndTaxFactory): 172 | """Receipt FCEA with a valid Vat, Tax and Optionals, ready to validate.""" 173 | 174 | total_amount = 13_000_000 175 | net_untaxed = 0 176 | net_taxed = 10_000_000 177 | 178 | @post_generation 179 | def post(obj: models.Receipt, create: bool, extracted: None, **kwargs) -> None: 180 | VatFactory( 181 | vat_type__code=5, 182 | receipt=obj, 183 | base_amount=10_000_000, 184 | amount=2_100_000, 185 | ) 186 | TaxFactory( 187 | tax_type__code=3, 188 | receipt=obj, 189 | base_amount=10_000_000, 190 | aliquot=9, 191 | amount=900_000, 192 | ) 193 | OptionalFactory(optional_type__code=2101, receipt=obj) 194 | # Value SCA stands for TRANSFERENCIA AL SISTEMA DE CIRCULACION ABIERTA 195 | OptionalFactory(optional_type__code=27, receipt=obj, value="SCA") 196 | 197 | 198 | class ReceiptWithInconsistentVatAndTaxFactory(ReceiptWithVatAndTaxFactory): 199 | """Receipt with a valid Vat and Tax, ready to validate.""" 200 | 201 | document_type = SubFactory(DocumentTypeFactory, code=80) 202 | 203 | @post_generation 204 | def post(obj: models.Receipt, create: bool, extracted: None, **kwargs) -> None: 205 | VatFactory(vat_type__code=5, receipt=obj) 206 | TaxFactory(tax_type__code=3, receipt=obj) 207 | 208 | 209 | class ReceiptWithApprovedValidation(ReceiptFactory): 210 | """Receipt with fake (e.g.: not live) approved validation.""" 211 | 212 | receipt_number = Sequence(lambda n: n + 1) 213 | 214 | @post_generation 215 | def post(obj: models.Receipt, create: bool, extracted: None, **kwargs) -> None: 216 | ReceiptValidationFactory(receipt=obj) 217 | 218 | 219 | class ReceiptValidationFactory(DjangoModelFactory): 220 | class Meta: 221 | model = models.ReceiptValidation 222 | 223 | result = models.ReceiptValidation.RESULT_APPROVED 224 | processed_date = make_aware(datetime(2017, 7, 2, 21, 6, 4)) 225 | cae = "67190616790549" 226 | cae_expiration = make_aware(datetime(2017, 7, 12)) 227 | receipt = SubFactory(ReceiptFactory, receipt_number=17) 228 | 229 | 230 | class ReceiptPDFFactory(DjangoModelFactory): 231 | class Meta: 232 | model = models.ReceiptPDF 233 | 234 | client_address = "La Rioja 123\nX5000EVX Córdoba" 235 | client_name = "John Doe" 236 | client_vat_condition = "Consumidor Final" 237 | gross_income_condition = "Convenio Multilateral" 238 | issuing_address = "Happy Street 123, CABA" 239 | issuing_name = "Alice Doe" 240 | receipt = SubFactory(ReceiptFactory) 241 | sales_terms = "Contado" 242 | vat_condition = "Responsable Monotributo" 243 | 244 | 245 | class ReceiptPDFWithFileFactory(ReceiptPDFFactory): 246 | receipt = SubFactory(ReceiptWithApprovedValidation) 247 | 248 | @post_generation 249 | def post(obj: models.ReceiptPDF, create: bool, extracted: None, **kwargs) -> None: 250 | obj.save_pdf(save_model=True) 251 | 252 | 253 | class GenericAfipTypeFactory(DjangoModelFactory): 254 | class Meta: 255 | model = models.GenericAfipType 256 | 257 | code = 80 258 | description = "CUIT" 259 | valid_from = datetime(2017, 8, 10) 260 | 261 | 262 | class VatTypeFactory(GenericAfipTypeFactory): 263 | class Meta: 264 | model = models.VatType 265 | 266 | code = 5 267 | description = "21%" 268 | 269 | 270 | class TaxTypeFactory(GenericAfipTypeFactory): 271 | class Meta: 272 | model = models.TaxType 273 | 274 | 275 | class OptionalTypeFactory(GenericAfipTypeFactory): 276 | class Meta: 277 | model = models.OptionalType 278 | 279 | code = 2101 280 | description = "Excepcion computo IVA Credito Fiscal" 281 | 282 | 283 | class VatFactory(DjangoModelFactory): 284 | class Meta: 285 | model = models.Vat 286 | 287 | amount = 21 288 | base_amount = 100 289 | receipt = SubFactory(ReceiptFactory) 290 | vat_type = SubFactory(VatTypeFactory) 291 | 292 | 293 | class TaxFactory(DjangoModelFactory): 294 | class Meta: 295 | model = models.Tax 296 | 297 | aliquot = 9 298 | amount = 9 299 | base_amount = 100 300 | description = "Test description" 301 | receipt = SubFactory(ReceiptFactory) 302 | tax_type = SubFactory(TaxTypeFactory) 303 | 304 | 305 | class OptionalFactory(DjangoModelFactory): 306 | class Meta: 307 | model = models.Optional 308 | 309 | # This value represent a valid CBU 310 | value = "1064169911100089878669" 311 | receipt = SubFactory(ReceiptFactory) 312 | optional_type = SubFactory(OptionalTypeFactory) 313 | 314 | 315 | class ReceiptEntryFactory(DjangoModelFactory): 316 | class Meta: 317 | model = models.ReceiptEntry 318 | 319 | receipt = SubFactory(ReceiptFactory) 320 | description = "Test Entry" 321 | vat = SubFactory(VatTypeFactory) 322 | 323 | 324 | class ClientVatConditionFactory(DjangoModelFactory): 325 | class Meta: 326 | model = models.ClientVatCondition 327 | django_get_or_create = ("code",) 328 | 329 | code = "5" 330 | description = "Consumidor Final" 331 | cmp_clase = "B,C" 332 | 333 | 334 | class ReceiptWithClientVatConditionFactory(ReceiptFactory): 335 | """Receipt with a valid Client VAT Condition, ready to validate.""" 336 | 337 | client_vat_condition = SubFactory(ClientVatConditionFactory) 338 | 339 | @post_generation 340 | def post(obj: models.Receipt, create: bool, extracted: None, **kwargs) -> None: 341 | VatFactory(vat_type__code=5, receipt=obj) 342 | TaxFactory(tax_type__code=3, receipt=obj) 343 | -------------------------------------------------------------------------------- /django_afip/fixtures/clientvatcondition.yaml: -------------------------------------------------------------------------------- 1 | - model: afip.clientvatcondition 2 | fields: 3 | code: '1' 4 | description: IVA Responsable Inscripto 5 | cmp_clase: 'A,M,C' 6 | - model: afip.clientvatcondition 7 | fields: 8 | code: '4' 9 | description: IVA Sujeto Exento 10 | cmp_clase: 'B,C' 11 | - model: afip.clientvatcondition 12 | fields: 13 | code: '5' 14 | description: Consumidor Final 15 | cmp_clase: 'B,C,49' 16 | - model: afip.clientvatcondition 17 | fields: 18 | code: '6' 19 | description: Responsable Monotributo 20 | cmp_clase: 'A,M,C' 21 | - model: afip.clientvatcondition 22 | fields: 23 | code: '7' 24 | description: Sujeto No Categorizado 25 | cmp_clase: 'B,C' 26 | - model: afip.clientvatcondition 27 | fields: 28 | code: '8' 29 | description: Proveedor del Exterior 30 | cmp_clase: 'B,C' 31 | - model: afip.clientvatcondition 32 | fields: 33 | code: '9' 34 | description: Cliente del Exterior 35 | cmp_clase: 'B,C' 36 | - model: afip.clientvatcondition 37 | fields: 38 | code: '10' 39 | description: IVA Liberado – Ley N° 19.640 40 | cmp_clase: 'B,C' 41 | - model: afip.clientvatcondition 42 | fields: 43 | code: '13' 44 | description: Monotributista Social 45 | cmp_clase: 'A,M,C' 46 | - model: afip.clientvatcondition 47 | fields: 48 | code: '15' 49 | description: IVA No Alcanzado 50 | cmp_clase: 'B,C' 51 | - model: afip.clientvatcondition 52 | fields: 53 | code: '16' 54 | description: Monotributo Trabajador Independiente Promovido 55 | cmp_clase: 'A,M,C' 56 | -------------------------------------------------------------------------------- /django_afip/fixtures/concepttype.yaml: -------------------------------------------------------------------------------- 1 | - model: afip.concepttype 2 | fields: 3 | code: '1' 4 | description: Producto 5 | valid_from: 2010-09-17 6 | valid_to: null 7 | - model: afip.concepttype 8 | fields: 9 | code: '2' 10 | description: Servicios 11 | valid_from: 2010-09-17 12 | valid_to: null 13 | - model: afip.concepttype 14 | fields: 15 | code: '3' 16 | description: Productos y Servicios 17 | valid_from: 2010-09-17 18 | valid_to: null 19 | -------------------------------------------------------------------------------- /django_afip/fixtures/currencytype.yaml: -------------------------------------------------------------------------------- 1 | - model: afip.currencytype 2 | fields: 3 | code: PES 4 | description: Pesos Argentinos 5 | valid_from: 2009-04-03 6 | valid_to: null 7 | - model: afip.currencytype 8 | fields: 9 | code: DOL 10 | description: Dólar Estadounidense 11 | valid_from: 2009-04-03 12 | valid_to: null 13 | - model: afip.currencytype 14 | fields: 15 | code: '002' 16 | description: Dólar Libre EEUU 17 | valid_from: 2009-04-16 18 | valid_to: null 19 | - model: afip.currencytype 20 | fields: 21 | code: '010' 22 | description: Pesos Mejicanos 23 | valid_from: 2009-04-03 24 | valid_to: null 25 | - model: afip.currencytype 26 | fields: 27 | code: '011' 28 | description: Pesos Uruguayos 29 | valid_from: 2009-04-03 30 | valid_to: null 31 | - model: afip.currencytype 32 | fields: 33 | code: '014' 34 | description: Coronas Danesas 35 | valid_from: 2009-04-03 36 | valid_to: null 37 | - model: afip.currencytype 38 | fields: 39 | code: '015' 40 | description: Coronas Noruegas 41 | valid_from: 2009-04-03 42 | valid_to: null 43 | - model: afip.currencytype 44 | fields: 45 | code: '016' 46 | description: Coronas Suecas 47 | valid_from: 2009-04-03 48 | valid_to: null 49 | - model: afip.currencytype 50 | fields: 51 | code: 018 52 | description: Dólar Canadiense 53 | valid_from: 2009-04-03 54 | valid_to: null 55 | - model: afip.currencytype 56 | fields: 57 | code: 019 58 | description: Yens 59 | valid_from: 2009-04-03 60 | valid_to: null 61 | - model: afip.currencytype 62 | fields: 63 | code: '021' 64 | description: Libra Esterlina 65 | valid_from: 2009-04-03 66 | valid_to: null 67 | - model: afip.currencytype 68 | fields: 69 | code: '023' 70 | description: Bolívar Venezolano 71 | valid_from: 2009-04-03 72 | valid_to: null 73 | - model: afip.currencytype 74 | fields: 75 | code: '024' 76 | description: Corona Checa 77 | valid_from: 2009-04-03 78 | valid_to: null 79 | - model: afip.currencytype 80 | fields: 81 | code: '025' 82 | description: Dinar Serbio 83 | valid_from: 2009-04-03 84 | valid_to: null 85 | - model: afip.currencytype 86 | fields: 87 | code: '026' 88 | description: Dólar Australiano 89 | valid_from: 2009-04-03 90 | valid_to: null 91 | - model: afip.currencytype 92 | fields: 93 | code: 028 94 | description: Florín (Antillas Holandesas) 95 | valid_from: 2009-04-03 96 | valid_to: null 97 | - model: afip.currencytype 98 | fields: 99 | code: 029 100 | description: Güaraní 101 | valid_from: 2009-04-03 102 | valid_to: null 103 | - model: afip.currencytype 104 | fields: 105 | code: '031' 106 | description: Peso Boliviano 107 | valid_from: 2009-04-03 108 | valid_to: null 109 | - model: afip.currencytype 110 | fields: 111 | code: '032' 112 | description: Peso Colombiano 113 | valid_from: 2009-04-03 114 | valid_to: null 115 | - model: afip.currencytype 116 | fields: 117 | code: '033' 118 | description: Peso Chileno 119 | valid_from: 2009-04-03 120 | valid_to: null 121 | - model: afip.currencytype 122 | fields: 123 | code: '034' 124 | description: Rand Sudafricano 125 | valid_from: 2009-04-03 126 | valid_to: null 127 | - model: afip.currencytype 128 | fields: 129 | code: '051' 130 | description: Dólar de Hong Kong 131 | valid_from: 2009-04-03 132 | valid_to: null 133 | - model: afip.currencytype 134 | fields: 135 | code: '052' 136 | description: Dólar de Singapur 137 | valid_from: 2009-04-03 138 | valid_to: null 139 | - model: afip.currencytype 140 | fields: 141 | code: '053' 142 | description: Dólar de Jamaica 143 | valid_from: 2009-04-03 144 | valid_to: null 145 | - model: afip.currencytype 146 | fields: 147 | code: '054' 148 | description: Dólar de Taiwan 149 | valid_from: 2009-04-03 150 | valid_to: null 151 | - model: afip.currencytype 152 | fields: 153 | code: '055' 154 | description: Quetzal Guatemalteco 155 | valid_from: 2009-04-03 156 | valid_to: null 157 | - model: afip.currencytype 158 | fields: 159 | code: '056' 160 | description: Forint (Hungría) 161 | valid_from: 2009-04-03 162 | valid_to: null 163 | - model: afip.currencytype 164 | fields: 165 | code: '057' 166 | description: Baht (Tailandia) 167 | valid_from: 2009-04-03 168 | valid_to: null 169 | - model: afip.currencytype 170 | fields: 171 | code: 059 172 | description: Dinar Kuwaiti 173 | valid_from: 2009-04-03 174 | valid_to: null 175 | - model: afip.currencytype 176 | fields: 177 | code: '012' 178 | description: Real 179 | valid_from: 2009-04-03 180 | valid_to: null 181 | - model: afip.currencytype 182 | fields: 183 | code: '030' 184 | description: Shekel (Israel) 185 | valid_from: 2009-04-03 186 | valid_to: null 187 | - model: afip.currencytype 188 | fields: 189 | code: '035' 190 | description: Nuevo Sol Peruano 191 | valid_from: 2009-04-03 192 | valid_to: null 193 | - model: afip.currencytype 194 | fields: 195 | code: '060' 196 | description: Euro 197 | valid_from: 2009-04-03 198 | valid_to: null 199 | - model: afip.currencytype 200 | fields: 201 | code: '040' 202 | description: Leu Rumano 203 | valid_from: 2009-04-15 204 | valid_to: null 205 | - model: afip.currencytype 206 | fields: 207 | code: '042' 208 | description: Peso Dominicano 209 | valid_from: 2009-04-15 210 | valid_to: null 211 | - model: afip.currencytype 212 | fields: 213 | code: '043' 214 | description: Balboas Panameñas 215 | valid_from: 2009-04-15 216 | valid_to: null 217 | - model: afip.currencytype 218 | fields: 219 | code: '044' 220 | description: Córdoba Nicaragüense 221 | valid_from: 2009-04-15 222 | valid_to: null 223 | - model: afip.currencytype 224 | fields: 225 | code: '045' 226 | description: Dirham Marroquí 227 | valid_from: 2009-04-15 228 | valid_to: null 229 | - model: afip.currencytype 230 | fields: 231 | code: '046' 232 | description: Libra Egipcia 233 | valid_from: 2009-04-15 234 | valid_to: null 235 | - model: afip.currencytype 236 | fields: 237 | code: '047' 238 | description: Riyal Saudita 239 | valid_from: 2009-04-15 240 | valid_to: null 241 | - model: afip.currencytype 242 | fields: 243 | code: '061' 244 | description: Zloty Polaco 245 | valid_from: 2009-04-15 246 | valid_to: null 247 | - model: afip.currencytype 248 | fields: 249 | code: '062' 250 | description: Rupia Hindú 251 | valid_from: 2009-04-15 252 | valid_to: null 253 | - model: afip.currencytype 254 | fields: 255 | code: '063' 256 | description: Lempira Hondureña 257 | valid_from: 2009-04-15 258 | valid_to: null 259 | - model: afip.currencytype 260 | fields: 261 | code: '064' 262 | description: Yuan (Rep. Pop. China) 263 | valid_from: 2009-04-15 264 | valid_to: null 265 | - model: afip.currencytype 266 | fields: 267 | code: 009 268 | description: Franco Suizo 269 | valid_from: 2009-11-10 270 | valid_to: null 271 | - model: afip.currencytype 272 | fields: 273 | code: '041' 274 | description: Derechos Especiales de Giro 275 | valid_from: 2010-01-25 276 | valid_to: null 277 | - model: afip.currencytype 278 | fields: 279 | code: 049 280 | description: Gramos de Oro Fino 281 | valid_from: 2010-01-25 282 | valid_to: null 283 | - model: afip.currencytype 284 | fields: 285 | code: RUB 286 | description: Rublo (Rusia) 287 | valid_from: 2025-01-15 288 | valid_to: null 289 | - model: afip.currencytype 290 | fields: 291 | code: NZD 292 | description: Dólar Neozelandes 293 | valid_from: 2025-01-15 294 | valid_to: null 295 | -------------------------------------------------------------------------------- /django_afip/fixtures/documenttype.yaml: -------------------------------------------------------------------------------- 1 | - model: afip.documenttype 2 | fields: 3 | code: '80' 4 | description: CUIT 5 | valid_from: 2008-07-25 6 | valid_to: null 7 | - model: afip.documenttype 8 | fields: 9 | code: '86' 10 | description: CUIL 11 | valid_from: 2008-07-25 12 | valid_to: null 13 | - model: afip.documenttype 14 | fields: 15 | code: '87' 16 | description: CDI 17 | valid_from: 2008-07-25 18 | valid_to: null 19 | - model: afip.documenttype 20 | fields: 21 | code: '89' 22 | description: LE 23 | valid_from: 2008-07-25 24 | valid_to: null 25 | - model: afip.documenttype 26 | fields: 27 | code: '90' 28 | description: LC 29 | valid_from: 2008-07-25 30 | valid_to: null 31 | - model: afip.documenttype 32 | fields: 33 | code: '91' 34 | description: CI Extranjera 35 | valid_from: 2008-07-25 36 | valid_to: null 37 | - model: afip.documenttype 38 | fields: 39 | code: '92' 40 | description: en trámite 41 | valid_from: 2008-07-25 42 | valid_to: null 43 | - model: afip.documenttype 44 | fields: 45 | code: '93' 46 | description: Acta Nacimiento 47 | valid_from: 2008-07-25 48 | valid_to: null 49 | - model: afip.documenttype 50 | fields: 51 | code: '95' 52 | description: CI Bs. As. RNP 53 | valid_from: 2008-07-25 54 | valid_to: null 55 | - model: afip.documenttype 56 | fields: 57 | code: '96' 58 | description: DNI 59 | valid_from: 2008-07-25 60 | valid_to: null 61 | - model: afip.documenttype 62 | fields: 63 | code: '94' 64 | description: Pasaporte 65 | valid_from: 2008-07-25 66 | valid_to: null 67 | - model: afip.documenttype 68 | fields: 69 | code: '0' 70 | description: CI Policía Federal 71 | valid_from: 2008-07-25 72 | valid_to: null 73 | - model: afip.documenttype 74 | fields: 75 | code: '1' 76 | description: CI Buenos Aires 77 | valid_from: 2008-07-25 78 | valid_to: null 79 | - model: afip.documenttype 80 | fields: 81 | code: '2' 82 | description: CI Catamarca 83 | valid_from: 2008-07-25 84 | valid_to: null 85 | - model: afip.documenttype 86 | fields: 87 | code: '3' 88 | description: CI Córdoba 89 | valid_from: 2008-07-25 90 | valid_to: null 91 | - model: afip.documenttype 92 | fields: 93 | code: '4' 94 | description: CI Corrientes 95 | valid_from: 2008-07-28 96 | valid_to: null 97 | - model: afip.documenttype 98 | fields: 99 | code: '5' 100 | description: CI Entre Ríos 101 | valid_from: 2008-07-28 102 | valid_to: null 103 | - model: afip.documenttype 104 | fields: 105 | code: '6' 106 | description: CI Jujuy 107 | valid_from: 2008-07-28 108 | valid_to: null 109 | - model: afip.documenttype 110 | fields: 111 | code: '7' 112 | description: CI Mendoza 113 | valid_from: 2008-07-28 114 | valid_to: null 115 | - model: afip.documenttype 116 | fields: 117 | code: '8' 118 | description: CI La Rioja 119 | valid_from: 2008-07-28 120 | valid_to: null 121 | - model: afip.documenttype 122 | fields: 123 | code: '9' 124 | description: CI Salta 125 | valid_from: 2008-07-28 126 | valid_to: null 127 | - model: afip.documenttype 128 | fields: 129 | code: '10' 130 | description: CI San Juan 131 | valid_from: 2008-07-28 132 | valid_to: null 133 | - model: afip.documenttype 134 | fields: 135 | code: '11' 136 | description: CI San Luis 137 | valid_from: 2008-07-28 138 | valid_to: null 139 | - model: afip.documenttype 140 | fields: 141 | code: '12' 142 | description: CI Santa Fe 143 | valid_from: 2008-07-28 144 | valid_to: null 145 | - model: afip.documenttype 146 | fields: 147 | code: '13' 148 | description: CI Santiago del Estero 149 | valid_from: 2008-07-28 150 | valid_to: null 151 | - model: afip.documenttype 152 | fields: 153 | code: '14' 154 | description: CI Tucumán 155 | valid_from: 2008-07-28 156 | valid_to: null 157 | - model: afip.documenttype 158 | fields: 159 | code: '16' 160 | description: CI Chaco 161 | valid_from: 2008-07-28 162 | valid_to: null 163 | - model: afip.documenttype 164 | fields: 165 | code: '17' 166 | description: CI Chubut 167 | valid_from: 2008-07-28 168 | valid_to: null 169 | - model: afip.documenttype 170 | fields: 171 | code: '18' 172 | description: CI Formosa 173 | valid_from: 2008-07-28 174 | valid_to: null 175 | - model: afip.documenttype 176 | fields: 177 | code: '19' 178 | description: CI Misiones 179 | valid_from: 2008-07-28 180 | valid_to: null 181 | - model: afip.documenttype 182 | fields: 183 | code: '20' 184 | description: CI Neuquén 185 | valid_from: 2008-07-28 186 | valid_to: null 187 | - model: afip.documenttype 188 | fields: 189 | code: '21' 190 | description: CI La Pampa 191 | valid_from: 2008-07-28 192 | valid_to: null 193 | - model: afip.documenttype 194 | fields: 195 | code: '22' 196 | description: CI Río Negro 197 | valid_from: 2008-07-28 198 | valid_to: null 199 | - model: afip.documenttype 200 | fields: 201 | code: '23' 202 | description: CI Santa Cruz 203 | valid_from: 2008-07-28 204 | valid_to: null 205 | - model: afip.documenttype 206 | fields: 207 | code: '24' 208 | description: CI Tierra del Fuego 209 | valid_from: 2008-07-28 210 | valid_to: null 211 | - model: afip.documenttype 212 | fields: 213 | code: '99' 214 | description: Doc. (Otro) 215 | valid_from: 2008-07-28 216 | valid_to: null 217 | -------------------------------------------------------------------------------- /django_afip/fixtures/optionaltype.yaml: -------------------------------------------------------------------------------- 1 | - model: afip.optionaltype 2 | fields: 3 | description: RG Empresas Promovidas - Indentificador de proyecto vinculado a Régimen 4 | de Promoción Industrial 5 | valid_from: 2010-09-17 6 | valid_to: null 7 | code: '2' 8 | - model: afip.optionaltype 9 | fields: 10 | description: RG Bienes Usados 3411 - Nombre y Apellido o Denominación del vendedor 11 | del bien usado. 12 | valid_from: 2013-04-01 13 | valid_to: null 14 | code: '91' 15 | - model: afip.optionaltype 16 | fields: 17 | description: RG Bienes Usados 3411 - Nacionalidad del vendedor del bien usado. 18 | valid_from: 2013-04-01 19 | valid_to: null 20 | code: '92' 21 | - model: afip.optionaltype 22 | fields: 23 | description: RG Bienes Usados 3411 - Domicilio del vendedor del bien usado. 24 | valid_from: 2013-04-01 25 | valid_to: null 26 | code: '93' 27 | - model: afip.optionaltype 28 | fields: 29 | description: Excepcion computo IVA Credito Fiscal 30 | valid_from: 2014-10-16 31 | valid_to: null 32 | code: '5' 33 | - model: afip.optionaltype 34 | fields: 35 | description: RG 3668 Impuesto al Valor Agregado - Art.12 IVA Firmante Doc Tipo 36 | valid_from: 2014-10-16 37 | valid_to: null 38 | code: '61' 39 | - model: afip.optionaltype 40 | fields: 41 | description: RG 3668 Impuesto al Valor Agregado - Art.12 IVA Firmante Doc Nro 42 | valid_from: 2014-10-16 43 | valid_to: null 44 | code: '62' 45 | - model: afip.optionaltype 46 | fields: 47 | description: RG 3668 Impuesto al Valor Agregado - Art.12 IVA Carácter del Firmante 48 | valid_from: 2014-10-16 49 | valid_to: null 50 | code: '7' 51 | - model: afip.optionaltype 52 | fields: 53 | description: RG 3.368 Establecimientos de educación pública de gestión privada 54 | - Actividad Comprendida 55 | valid_from: 2015-06-05 56 | valid_to: null 57 | code: '10' 58 | - model: afip.optionaltype 59 | fields: 60 | description: RG 3.368 Establecimientos de educación pública de gestión privada 61 | - Tipo de Documento 62 | valid_from: 2015-06-05 63 | valid_to: null 64 | code: '1011' 65 | - model: afip.optionaltype 66 | fields: 67 | description: RG 3.368 Establecimientos de educación pública de gestión privada 68 | - Número de Documento 69 | valid_from: 2015-06-05 70 | valid_to: null 71 | code: '1012' 72 | - model: afip.optionaltype 73 | fields: 74 | description: RG 2.820 Operaciones económicas vinculadas con bienes inmuebles - 75 | Actividad Comprendida 76 | valid_from: 2015-06-05 77 | valid_to: null 78 | code: '11' 79 | - model: afip.optionaltype 80 | fields: 81 | description: RG 3.687 Locación temporaria de inmuebles con fines turísticos - 82 | Actividad Comprendida 83 | valid_from: 2015-06-05 84 | valid_to: null 85 | code: '12' 86 | - model: afip.optionaltype 87 | fields: 88 | description: RG 2.863 Representantes de Modelos 89 | valid_from: 2016-01-01 90 | valid_to: null 91 | code: '13' 92 | - model: afip.optionaltype 93 | fields: 94 | description: RG 2.863 Agencias de publicidad 95 | valid_from: 2016-01-01 96 | valid_to: null 97 | code: '14' 98 | - model: afip.optionaltype 99 | fields: 100 | description: RG 2.863 Personas físicas que desarrollen actividad de modelaje 101 | valid_from: 2016-01-01 102 | valid_to: null 103 | code: '15' 104 | - model: afip.optionaltype 105 | fields: 106 | description: RG 4004-E Locación de inmuebles destino 'casa-habitación'. Dato 2 107 | (dos) = facturación directa / Dato 1 (uno) = facturación a través de intermediario 108 | valid_from: 2017-03-09 109 | valid_to: null 110 | code: '17' 111 | - model: afip.optionaltype 112 | fields: 113 | description: RG 4004-E Locación de inmuebles destino 'casa-habitación'. Clave 114 | Única de Identificación Tributaria (CUIT). 115 | valid_from: 2017-03-09 116 | valid_to: null 117 | code: '1801' 118 | - model: afip.optionaltype 119 | fields: 120 | description: RG 4004-E Locación de inmuebles destino 'casa-habitación'. Apellido 121 | y nombres, denominación y/o razón social. 122 | valid_from: 2017-03-09 123 | valid_to: null 124 | code: '1802' 125 | - model: afip.optionaltype 126 | fields: 127 | description: Factura de Crédito Electrónica MiPyMEs (FCE) - CBU del Emisor 128 | valid_from: 2018-12-26 129 | valid_to: null 130 | code: '2101' 131 | - model: afip.optionaltype 132 | fields: 133 | description: Factura de Crédito Electrónica MiPyMEs (FCE) - Alias del Emisor 134 | valid_from: 2018-12-26 135 | valid_to: null 136 | code: '2102' 137 | - model: afip.optionaltype 138 | fields: 139 | description: Factura de Crédito Electrónica MiPyMEs (FCE) - Anulación 140 | valid_from: 2018-12-26 141 | valid_to: null 142 | code: '22' 143 | - model: afip.optionaltype 144 | fields: 145 | description: Factura de Crédito Electrónica MiPyMEs (FCE) - Referencia Comercial 146 | valid_from: 2019-03-08 147 | valid_to: null 148 | code: '23' 149 | - model: afip.optionaltype 150 | fields: 151 | description: Factura de Credito Electronica MiPyMEs (FCE) - Transferencia 152 | valid_from: 2020-11-26 153 | valid_to: null 154 | code: '27' 155 | -------------------------------------------------------------------------------- /django_afip/fixtures/receipttype.yaml: -------------------------------------------------------------------------------- 1 | - model: afip.receipttype 2 | fields: 3 | code: '1' 4 | description: Factura A 5 | valid_from: 2010-09-17 6 | valid_to: null 7 | - model: afip.receipttype 8 | fields: 9 | code: '2' 10 | description: Nota de Débito A 11 | valid_from: 2010-09-17 12 | valid_to: null 13 | - model: afip.receipttype 14 | fields: 15 | code: '3' 16 | description: Nota de Crédito A 17 | valid_from: 2010-09-17 18 | valid_to: null 19 | - model: afip.receipttype 20 | fields: 21 | code: '6' 22 | description: Factura B 23 | valid_from: 2010-09-17 24 | valid_to: null 25 | - model: afip.receipttype 26 | fields: 27 | code: '7' 28 | description: Nota de Débito B 29 | valid_from: 2010-09-17 30 | valid_to: null 31 | - model: afip.receipttype 32 | fields: 33 | code: '8' 34 | description: Nota de Crédito B 35 | valid_from: 2010-09-17 36 | valid_to: null 37 | - model: afip.receipttype 38 | fields: 39 | code: '4' 40 | description: Recibos A 41 | valid_from: 2010-09-17 42 | valid_to: null 43 | - model: afip.receipttype 44 | fields: 45 | code: '5' 46 | description: Notas de Venta al contado A 47 | valid_from: 2010-09-17 48 | valid_to: null 49 | - model: afip.receipttype 50 | fields: 51 | code: '9' 52 | description: Recibos B 53 | valid_from: 2010-09-17 54 | valid_to: null 55 | - model: afip.receipttype 56 | fields: 57 | code: '10' 58 | description: Notas de Venta al contado B 59 | valid_from: 2010-09-17 60 | valid_to: null 61 | - model: afip.receipttype 62 | fields: 63 | code: '63' 64 | description: Liquidacion A 65 | valid_from: 2010-09-17 66 | valid_to: null 67 | - model: afip.receipttype 68 | fields: 69 | code: '64' 70 | description: Liquidacion B 71 | valid_from: 2010-09-17 72 | valid_to: null 73 | - model: afip.receipttype 74 | fields: 75 | code: '34' 76 | description: Cbtes. A del Anexo I, Apartado A,inc.f),R.G.Nro. 1415 77 | valid_from: 2010-09-17 78 | valid_to: null 79 | - model: afip.receipttype 80 | fields: 81 | code: '35' 82 | description: Cbtes. B del Anexo I,Apartado A,inc. f),R.G. Nro. 1415 83 | valid_from: 2010-09-17 84 | valid_to: null 85 | - model: afip.receipttype 86 | fields: 87 | code: '39' 88 | description: Otros comprobantes A que cumplan con R.G.Nro. 1415 89 | valid_from: 2010-09-17 90 | valid_to: null 91 | - model: afip.receipttype 92 | fields: 93 | code: '40' 94 | description: Otros comprobantes B que cumplan con R.G.Nro. 1415 95 | valid_from: 2010-09-17 96 | valid_to: null 97 | - model: afip.receipttype 98 | fields: 99 | code: '60' 100 | description: Cta de Vta y Liquido prod. A 101 | valid_from: 2010-09-17 102 | valid_to: null 103 | - model: afip.receipttype 104 | fields: 105 | code: '61' 106 | description: Cta de Vta y Liquido prod. B 107 | valid_from: 2010-09-17 108 | valid_to: null 109 | - model: afip.receipttype 110 | fields: 111 | code: '11' 112 | description: Factura C 113 | valid_from: 2011-03-30 114 | valid_to: null 115 | - model: afip.receipttype 116 | fields: 117 | code: '12' 118 | description: Nota de Débito C 119 | valid_from: 2011-03-30 120 | valid_to: null 121 | - model: afip.receipttype 122 | fields: 123 | code: '13' 124 | description: Nota de Crédito C 125 | valid_from: 2011-03-30 126 | valid_to: null 127 | - model: afip.receipttype 128 | fields: 129 | code: '15' 130 | description: Recibo C 131 | valid_from: 2011-03-30 132 | valid_to: null 133 | - model: afip.receipttype 134 | fields: 135 | code: '49' 136 | description: Comprobante de Compra de Bienes Usados a Consumidor Final 137 | valid_from: 2013-04-01 138 | valid_to: null 139 | - model: afip.receipttype 140 | fields: 141 | code: '51' 142 | description: Factura M 143 | valid_from: 2015-05-22 144 | valid_to: null 145 | - model: afip.receipttype 146 | fields: 147 | code: '52' 148 | description: Nota de Débito M 149 | valid_from: 2015-05-22 150 | valid_to: null 151 | - model: afip.receipttype 152 | fields: 153 | code: '53' 154 | description: Nota de Crédito M 155 | valid_from: 2015-05-22 156 | valid_to: null 157 | - model: afip.receipttype 158 | fields: 159 | code: '54' 160 | description: Recibo M 161 | valid_from: 2015-05-22 162 | valid_to: null 163 | - model: afip.receipttype 164 | fields: 165 | code: '201' 166 | description: Factura de Crédito electrónica MiPyMEs (FCE) A 167 | valid_from: 2018-12-26 168 | valid_to: null 169 | - model: afip.receipttype 170 | fields: 171 | code: '202' 172 | description: Nota de Débito electrónica MiPyMEs (FCE) A 173 | valid_from: 2018-12-26 174 | valid_to: null 175 | - model: afip.receipttype 176 | fields: 177 | code: '203' 178 | description: Nota de Crédito electrónica MiPyMEs (FCE) A 179 | valid_from: 2018-12-26 180 | valid_to: null 181 | - model: afip.receipttype 182 | fields: 183 | code: '206' 184 | description: Factura de Crédito electrónica MiPyMEs (FCE) B 185 | valid_from: 2018-12-26 186 | valid_to: null 187 | - model: afip.receipttype 188 | fields: 189 | code: '207' 190 | description: Nota de Débito electrónica MiPyMEs (FCE) B 191 | valid_from: 2018-12-26 192 | valid_to: null 193 | - model: afip.receipttype 194 | fields: 195 | code: '208' 196 | description: Nota de Crédito electrónica MiPyMEs (FCE) B 197 | valid_from: 2018-12-26 198 | valid_to: null 199 | - model: afip.receipttype 200 | fields: 201 | code: '211' 202 | description: Factura de Crédito electrónica MiPyMEs (FCE) C 203 | valid_from: 2018-12-26 204 | valid_to: null 205 | - model: afip.receipttype 206 | fields: 207 | code: '212' 208 | description: Nota de Débito electrónica MiPyMEs (FCE) C 209 | valid_from: 2018-12-26 210 | valid_to: null 211 | - model: afip.receipttype 212 | fields: 213 | code: '213' 214 | description: Nota de Crédito electrónica MiPyMEs (FCE) C 215 | valid_from: 2018-12-26 216 | valid_to: null 217 | -------------------------------------------------------------------------------- /django_afip/fixtures/taxtype.yaml: -------------------------------------------------------------------------------- 1 | - model: afip.taxtype 2 | fields: 3 | code: '1' 4 | description: Impuestos nacionales 5 | valid_from: 2010-09-17 6 | valid_to: null 7 | - model: afip.taxtype 8 | fields: 9 | code: '2' 10 | description: Impuestos provinciales 11 | valid_from: 2010-09-17 12 | valid_to: null 13 | - model: afip.taxtype 14 | fields: 15 | code: '3' 16 | description: Impuestos municipales 17 | valid_from: 2010-09-17 18 | valid_to: null 19 | - model: afip.taxtype 20 | fields: 21 | code: '4' 22 | description: Impuestos Internos 23 | valid_from: 2010-09-17 24 | valid_to: null 25 | - model: afip.taxtype 26 | fields: 27 | code: '99' 28 | description: Otro 29 | valid_from: 2010-09-17 30 | valid_to: null 31 | - model: afip.taxtype 32 | fields: 33 | code: '5' 34 | description: IIBB 35 | valid_from: 2017-07-19 36 | valid_to: null 37 | - model: afip.taxtype 38 | fields: 39 | code: '6' 40 | description: Percepción de IVA 41 | valid_from: 2017-07-19 42 | valid_to: null 43 | - model: afip.taxtype 44 | fields: 45 | code: '7' 46 | description: Percepción de IIBB 47 | valid_from: 2017-07-19 48 | valid_to: null 49 | - model: afip.taxtype 50 | fields: 51 | code: '8' 52 | description: Percepciones por Impuestos Municipales 53 | valid_from: 2017-07-19 54 | valid_to: null 55 | - model: afip.taxtype 56 | fields: 57 | code: '9' 58 | description: Otras Percepciones 59 | valid_from: 2017-07-19 60 | valid_to: null 61 | - model: afip.taxtype 62 | fields: 63 | code: '13' 64 | description: Percepción de IVA a no Categorizado 65 | valid_from: 2017-07-19 66 | valid_to: null 67 | -------------------------------------------------------------------------------- /django_afip/fixtures/vattype.yaml: -------------------------------------------------------------------------------- 1 | - model: afip.vattype 2 | fields: 3 | code: '3' 4 | description: 0% 5 | valid_from: 2009-02-20 6 | valid_to: null 7 | - model: afip.vattype 8 | fields: 9 | code: '4' 10 | description: 10.5% 11 | valid_from: 2009-02-20 12 | valid_to: null 13 | - model: afip.vattype 14 | fields: 15 | code: '5' 16 | description: 21% 17 | valid_from: 2009-02-20 18 | valid_to: null 19 | - model: afip.vattype 20 | fields: 21 | code: '6' 22 | description: 27% 23 | valid_from: 2009-02-20 24 | valid_to: null 25 | - model: afip.vattype 26 | fields: 27 | code: '8' 28 | description: 5% 29 | valid_from: 2014-10-20 30 | valid_to: null 31 | - model: afip.vattype 32 | fields: 33 | code: '9' 34 | description: 2.5% 35 | valid_from: 2014-10-20 36 | valid_to: null 37 | -------------------------------------------------------------------------------- /django_afip/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from django_afip import clients 6 | 7 | 8 | @dataclass(frozen=True) 9 | class ServerStatus: 10 | """A dataclass holding the server's reported status. 11 | 12 | An instance is truthy if all services are okay, or evaluates to ``False`` 13 | if at least one isn't:: 14 | 15 | if not server_status: 16 | print("At least one service is down") 17 | else 18 | print("All serivces are up") 19 | """ 20 | 21 | #: Whether the application server is working. 22 | app: bool 23 | #: Whether the database server is working. 24 | db: bool 25 | #: Whether the authentication server is working. 26 | auth: bool 27 | 28 | def __bool__(self) -> bool: 29 | return self.app and self.db and self.auth 30 | 31 | 32 | def get_server_status(production: bool) -> ServerStatus: 33 | """Return the status of AFIP's WS servers 34 | 35 | :param production: Whether to check the production servers. If false, the 36 | testing servers will be checked instead. 37 | """ 38 | client = clients.get_client("wsfe", not production) 39 | response = client.service.FEDummy() 40 | 41 | return ServerStatus( 42 | app=response["AppServer"] == "OK", 43 | db=response["DbServer"] == "OK", 44 | auth=response["AuthServer"] == "OK", 45 | ) 46 | -------------------------------------------------------------------------------- /django_afip/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhyNotHugo/django-afip/07d50b6af2315121a54b090f7566f812d77cf47f/django_afip/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /django_afip/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhyNotHugo/django-afip/07d50b6af2315121a54b090f7566f812d77cf47f/django_afip/management/commands/__init__.py -------------------------------------------------------------------------------- /django_afip/management/commands/afipmetadata.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.utils.translation import gettext as _ 5 | 6 | from django_afip import models 7 | 8 | 9 | class Command(BaseCommand): 10 | help = _("Loads fixtures with metadata from AFIP.") 11 | requires_migrations_checks = True 12 | 13 | def handle(self, *args, **options) -> None: 14 | models.load_metadata() 15 | -------------------------------------------------------------------------------- /django_afip/migrations/0002_taxpayerextras.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("afip", "0001_squashed_0036_receiptpdf__client_address__blank"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="TaxPayerExtras", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ( 27 | "logo", 28 | models.ImageField( 29 | blank=True, 30 | help_text="A logo to use when generating printable receipts.", 31 | null=True, 32 | upload_to="afip/taxpayers/logos/", 33 | verbose_name="pdf file", 34 | ), 35 | ), 36 | ( 37 | "taxpayer", 38 | models.OneToOneField( 39 | on_delete=django.db.models.deletion.CASCADE, 40 | related_name="extras", 41 | to="afip.TaxPayer", 42 | verbose_name="taxpayer", 43 | ), 44 | ), 45 | ], 46 | options={ 47 | "verbose_name": "taxpayer extras", 48 | "verbose_name_plural": "taxpayers extras", 49 | }, 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /django_afip/migrations/0003_issuance_type_length.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2019-01-29 04:36 2 | from __future__ import annotations 3 | 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("afip", "0002_taxpayerextras"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="pointofsales", 16 | name="issuance_type", 17 | field=models.CharField( 18 | help_text="Indicates if this POS emits using CAE and CAEA.", 19 | max_length=24, 20 | verbose_name="issuance type", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /django_afip/migrations/0004_storages_and_help_texts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | import django_afip.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("afip", "0003_issuance_type_length"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="receiptpdf", 17 | name="pdf_file", 18 | field=models.FileField( 19 | blank=True, 20 | help_text="The actual file which contains the PDF data.", 21 | null=True, 22 | storage=django_afip.models._get_storage_from_settings( 23 | "AFIP_PDF_STORAGE" 24 | ), 25 | upload_to=django_afip.models.ReceiptPDF.upload_to, 26 | verbose_name="pdf file", 27 | ), 28 | ), 29 | migrations.AlterField( 30 | model_name="taxpayer", 31 | name="certificate", 32 | field=models.FileField( 33 | blank=True, 34 | null=True, 35 | storage=django_afip.models._get_storage_from_settings( 36 | "AFIP_CERT_STORAGE" 37 | ), 38 | upload_to="afip/taxpayers/certs/", 39 | verbose_name="certificate", 40 | ), 41 | ), 42 | migrations.AlterField( 43 | model_name="taxpayer", 44 | name="key", 45 | field=models.FileField( 46 | blank=True, 47 | null=True, 48 | storage=django_afip.models._get_storage_from_settings( 49 | "AFIP_KEY_STORAGE" 50 | ), 51 | upload_to="afip/taxpayers/keys/", 52 | verbose_name="key", 53 | ), 54 | ), 55 | migrations.AlterField( 56 | model_name="taxpayerextras", 57 | name="logo", 58 | field=models.ImageField( 59 | blank=True, 60 | help_text="A logo to use when generating printable receipts.", 61 | null=True, 62 | storage=django_afip.models._get_storage_from_settings( 63 | "AFIP_LOGO_STORAGE" 64 | ), 65 | upload_to="afip/taxpayers/logos/", 66 | verbose_name="logo", 67 | ), 68 | ), 69 | ] 70 | -------------------------------------------------------------------------------- /django_afip/migrations/0005_flatten_taxpayer_extras.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import django.core.files.storage 6 | from django.db import migrations 7 | from django.db import models 8 | 9 | if TYPE_CHECKING: 10 | from django.apps.registry import Apps 11 | from django.db.backends.base.schema import BaseDatabaseSchemaEditor 12 | 13 | 14 | def merge_taxpayer_extras(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: 15 | TaxPayerExtras = apps.get_model("afip", "TaxPayerExtras") 16 | 17 | for extras in TaxPayerExtras.objects.all(): # pragma: no cover 18 | extras.taxpayer.logo = extras.logo 19 | extras.taxpayer.save() 20 | 21 | 22 | class Migration(migrations.Migration): 23 | dependencies = [ 24 | ("afip", "0004_storages_and_help_texts"), 25 | ] 26 | 27 | operations = [ 28 | migrations.AddField( 29 | model_name="taxpayer", 30 | name="logo", 31 | field=models.ImageField( 32 | blank=True, 33 | help_text="A logo to use when generating printable receipts.", 34 | null=True, 35 | storage=django.core.files.storage.FileSystemStorage(), 36 | upload_to="afip/taxpayers/logos/", 37 | verbose_name="logo", 38 | ), 39 | ), 40 | migrations.RunPython(merge_taxpayer_extras), 41 | ] 42 | -------------------------------------------------------------------------------- /django_afip/migrations/0006_delete_taxpayerextras.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("afip", "0005_flatten_taxpayer_extras"), 9 | ] 10 | 11 | operations = [ 12 | migrations.DeleteModel( 13 | name="TaxPayerExtras", 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /django_afip/migrations/0007_auto_20210409_1641.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("afip", "0006_delete_taxpayerextras"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="authticket", 16 | name="service", 17 | field=models.CharField( 18 | help_text="Service for which this ticket has been authorized.", 19 | max_length=6, 20 | verbose_name="service", 21 | ), 22 | ), 23 | migrations.AlterField( 24 | model_name="receipt", 25 | name="currency_quote", 26 | field=models.DecimalField( 27 | decimal_places=6, 28 | default=1, 29 | help_text="The currency's quote on the day this receipt was issued.", 30 | max_digits=10, 31 | verbose_name="currency quote", 32 | ), 33 | ), 34 | migrations.AlterField( 35 | model_name="receipt", 36 | name="document_number", 37 | field=models.BigIntegerField( 38 | help_text="The document number of the recipient of this receipt.", 39 | verbose_name="document number", 40 | ), 41 | ), 42 | migrations.AlterField( 43 | model_name="receipt", 44 | name="document_type", 45 | field=models.ForeignKey( 46 | help_text="The document type of the recipient of this receipt.", 47 | on_delete=django.db.models.deletion.PROTECT, 48 | related_name="receipts", 49 | to="afip.documenttype", 50 | verbose_name="document type", 51 | ), 52 | ), 53 | migrations.AlterField( 54 | model_name="receipt", 55 | name="exempt_amount", 56 | field=models.DecimalField( 57 | decimal_places=2, 58 | help_text=( 59 | "Only for categories which are tax-exempt.
" 60 | "For C-type receipts, this must be zero." 61 | ), 62 | max_digits=15, 63 | verbose_name="exempt amount", 64 | ), 65 | ), 66 | migrations.AlterField( 67 | model_name="receipt", 68 | name="issued_date", 69 | field=models.DateField( 70 | help_text="Can diverge up to 5 days for good, or 10 days otherwise.", 71 | verbose_name="issued date", 72 | ), 73 | ), 74 | migrations.AlterField( 75 | model_name="receipt", 76 | name="net_taxed", 77 | field=models.DecimalField( 78 | decimal_places=2, 79 | help_text=( 80 | "The total amount to which taxes apply.
" 81 | "For C-type receipts, this is equal to the subtotal." 82 | ), 83 | max_digits=15, 84 | verbose_name="total taxable amount", 85 | ), 86 | ), 87 | migrations.AlterField( 88 | model_name="receipt", 89 | name="net_untaxed", 90 | field=models.DecimalField( 91 | decimal_places=2, 92 | help_text=( 93 | "The total amount to which taxes do not apply.
" 94 | "For C-type receipts, this must be zero." 95 | ), 96 | max_digits=15, 97 | verbose_name="total untaxable amount", 98 | ), 99 | ), 100 | migrations.AlterField( 101 | model_name="receiptpdf", 102 | name="sales_terms", 103 | field=models.CharField( 104 | help_text='Should be something like "Cash", "Payable in 30 days", etc.', 105 | max_length=48, 106 | verbose_name="sales terms", 107 | ), 108 | ), 109 | migrations.AlterField( 110 | model_name="receiptvalidation", 111 | name="cae", 112 | field=models.CharField( 113 | help_text="The CAE as returned by the AFIP.", 114 | max_length=14, 115 | verbose_name="cae", 116 | ), 117 | ), 118 | migrations.AlterField( 119 | model_name="receiptvalidation", 120 | name="cae_expiration", 121 | field=models.DateField( 122 | help_text="The CAE expiration as returned by the AFIP.", 123 | verbose_name="cae expiration", 124 | ), 125 | ), 126 | migrations.AlterField( 127 | model_name="receiptvalidation", 128 | name="receipt", 129 | field=models.OneToOneField( 130 | help_text="The Receipt for which this validation applies.", 131 | on_delete=django.db.models.deletion.PROTECT, 132 | related_name="validation", 133 | to="afip.receipt", 134 | verbose_name="receipt", 135 | ), 136 | ), 137 | migrations.AlterField( 138 | model_name="receiptvalidation", 139 | name="result", 140 | field=models.CharField( 141 | choices=[("A", "approved"), ("R", "rejected")], 142 | help_text="Indicates whether the validation was succesful or not.", 143 | max_length=1, 144 | verbose_name="result", 145 | ), 146 | ), 147 | migrations.AlterField( 148 | model_name="taxpayer", 149 | name="certificate_expiration", 150 | field=models.DateTimeField( 151 | editable=False, 152 | help_text=( 153 | "Stores expiration for the current certificate.
" 154 | "Note that this field is updated pre-save, so the value may be " 155 | "invalid for unsaved models." 156 | ), 157 | null=True, 158 | verbose_name="certificate expiration", 159 | ), 160 | ), 161 | migrations.AlterField( 162 | model_name="taxpayer", 163 | name="is_sandboxed", 164 | field=models.BooleanField( 165 | help_text=( 166 | "Indicates if this taxpayer should use with the sandbox " 167 | "servers rather than the production servers." 168 | ), 169 | verbose_name="is sandboxed", 170 | ), 171 | ), 172 | migrations.AlterField( 173 | model_name="taxpayerprofile", 174 | name="issuing_address", 175 | field=models.TextField( 176 | help_text="The address of the issuing entity as shown on receipts.", 177 | verbose_name="issuing address", 178 | ), 179 | ), 180 | migrations.AlterField( 181 | model_name="taxpayerprofile", 182 | name="issuing_email", 183 | field=models.CharField( 184 | blank=True, 185 | help_text="The email of the issuing entity as shown on receipts.", 186 | max_length=128, 187 | null=True, 188 | verbose_name="issuing email", 189 | ), 190 | ), 191 | migrations.AlterField( 192 | model_name="taxpayerprofile", 193 | name="issuing_name", 194 | field=models.CharField( 195 | help_text="The name of the issuing entity as shown on receipts.", 196 | max_length=128, 197 | verbose_name="issuing name", 198 | ), 199 | ), 200 | ] 201 | -------------------------------------------------------------------------------- /django_afip/migrations/0008_move_taxpayerprofile_to_pos.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | if TYPE_CHECKING: 9 | from django.apps.registry import Apps 10 | from django.db.backends.base.schema import BaseDatabaseSchemaEditor 11 | 12 | 13 | def merge_taxpayer_profile(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: 14 | TaxPayerProfile = apps.get_model("afip", "TaxPayerProfile") 15 | 16 | for profile in TaxPayerProfile.objects.all(): # pragma: no cover 17 | for pos in profile.taxpayer.points_of_sales.all(): 18 | pos.issuing_name = profile.issuing_name 19 | pos.issuing_address = profile.issuing_address 20 | pos.issuing_email = profile.issuing_email 21 | pos.vat_condition = profile.vat_condition 22 | pos.gross_income_condition = profile.gross_income_condition 23 | pos.sales_terms = profile.sales_terms 24 | pos.save() 25 | 26 | 27 | class Migration(migrations.Migration): 28 | dependencies = [ 29 | ("afip", "0007_auto_20210409_1641"), 30 | ] 31 | 32 | operations = [ 33 | migrations.AddField( 34 | model_name="pointofsales", 35 | name="gross_income_condition", 36 | field=models.CharField( 37 | max_length=48, null=True, verbose_name="gross income condition" 38 | ), 39 | ), 40 | migrations.AddField( 41 | model_name="pointofsales", 42 | name="issuing_address", 43 | field=models.TextField( 44 | help_text="The address of the issuing entity as shown on receipts.", 45 | null=True, 46 | verbose_name="issuing address", 47 | ), 48 | ), 49 | migrations.AddField( 50 | model_name="pointofsales", 51 | name="issuing_email", 52 | field=models.CharField( 53 | blank=True, 54 | help_text="The email of the issuing entity as shown on receipts.", 55 | max_length=128, 56 | null=True, 57 | verbose_name="issuing email", 58 | ), 59 | ), 60 | migrations.AddField( 61 | model_name="pointofsales", 62 | name="issuing_name", 63 | field=models.CharField( 64 | help_text="The name of the issuing entity as shown on receipts.", 65 | max_length=128, 66 | null=True, 67 | verbose_name="issuing name", 68 | ), 69 | ), 70 | migrations.AddField( 71 | model_name="pointofsales", 72 | name="sales_terms", 73 | field=models.CharField( 74 | help_text=( 75 | "The terms of the sale printed onto receipts by default " 76 | "(eg: single payment, checking account, etc)." 77 | ), 78 | max_length=48, 79 | null=True, 80 | verbose_name="sales terms", 81 | ), 82 | ), 83 | migrations.AddField( 84 | model_name="pointofsales", 85 | name="vat_condition", 86 | field=models.CharField( 87 | choices=[ 88 | ("IVA Responsable Inscripto", "IVA Responsable Inscripto"), 89 | ("IVA Responsable No Inscripto", "IVA Responsable No Inscripto"), 90 | ("IVA Exento", "IVA Exento"), 91 | ("No Responsable IVA", "No Responsable IVA"), 92 | ("Responsable Monotributo", "Responsable Monotributo"), 93 | ], 94 | max_length=48, 95 | null=True, 96 | verbose_name="vat condition", 97 | ), 98 | ), 99 | migrations.RunPython(merge_taxpayer_profile), 100 | migrations.DeleteModel( 101 | name="TaxPayerProfile", 102 | ), 103 | ] 104 | -------------------------------------------------------------------------------- /django_afip/migrations/0009_alter_pointofsales_issuance_type.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("afip", "0008_move_taxpayerprofile_to_pos"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="pointofsales", 15 | name="issuance_type", 16 | field=models.CharField( 17 | help_text="Indicates if this POS emits using CAE and CAEA.", 18 | max_length=200, 19 | verbose_name="issuance type", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /django_afip/migrations/0010_alter_authticket_service.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("afip", "0009_alter_pointofsales_issuance_type"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="authticket", 15 | name="service", 16 | field=models.CharField( 17 | help_text="Service for which this ticket has been authorized.", 18 | max_length=34, 19 | verbose_name="service", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /django_afip/migrations/0011_receiptentry_discount_and_more.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from decimal import Decimal 4 | 5 | import django.core.validators 6 | import django.db.models.expressions 7 | from django.db import migrations 8 | from django.db import models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | ("afip", "0010_alter_authticket_service"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name="receiptentry", 19 | name="discount", 20 | field=models.DecimalField( 21 | decimal_places=2, 22 | default=0, 23 | help_text="Total net discount applied to row's total.", 24 | max_digits=15, 25 | validators=[django.core.validators.MinValueValidator(Decimal("0.0"))], 26 | verbose_name="discount", 27 | ), 28 | ), 29 | migrations.AddConstraint( 30 | model_name="receiptentry", 31 | constraint=models.CheckConstraint( 32 | check=models.Q(("discount__gte", Decimal("0.0"))), 33 | name="discount_positive_value", 34 | ), 35 | ), 36 | migrations.AddConstraint( 37 | model_name="receiptentry", 38 | constraint=models.CheckConstraint( 39 | check=models.Q( 40 | ( 41 | "discount__lte", 42 | django.db.models.expressions.CombinedExpression( 43 | django.db.models.expressions.F("quantity"), 44 | "*", 45 | django.db.models.expressions.F("unit_price"), 46 | ), 47 | ) 48 | ), 49 | name="discount_less_than_total", 50 | ), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /django_afip/migrations/0012_optionaltype_optional_alter_code_in_generics.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("afip", "0011_receiptentry_discount_and_more"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="OptionalType", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("code", models.CharField(max_length=4, verbose_name="code")), 27 | ( 28 | "description", 29 | models.CharField(max_length=250, verbose_name="description"), 30 | ), 31 | ( 32 | "valid_from", 33 | models.DateField(blank=True, null=True, verbose_name="valid from"), 34 | ), 35 | ( 36 | "valid_to", 37 | models.DateField(blank=True, null=True, verbose_name="valid until"), 38 | ), 39 | ], 40 | options={ 41 | "verbose_name": "optional type", 42 | "verbose_name_plural": "optional types", 43 | }, 44 | ), 45 | migrations.CreateModel( 46 | name="Optional", 47 | fields=[ 48 | ( 49 | "id", 50 | models.AutoField( 51 | auto_created=True, 52 | primary_key=True, 53 | serialize=False, 54 | verbose_name="ID", 55 | ), 56 | ), 57 | ( 58 | "value", 59 | models.CharField(max_length=250, verbose_name="optional value"), 60 | ), 61 | ( 62 | "optional_type", 63 | models.ForeignKey( 64 | on_delete=django.db.models.deletion.PROTECT, 65 | to="afip.optionaltype", 66 | verbose_name="optional type", 67 | ), 68 | ), 69 | ( 70 | "receipt", 71 | models.ForeignKey( 72 | on_delete=django.db.models.deletion.PROTECT, 73 | related_name="optionals", 74 | to="afip.receipt", 75 | ), 76 | ), 77 | ], 78 | options={ 79 | "verbose_name": "optional", 80 | "verbose_name_plural": "optionals", 81 | }, 82 | ), 83 | ] 84 | -------------------------------------------------------------------------------- /django_afip/migrations/0013_alter_receiptentry_quantity.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-09-22 20:38 2 | from __future__ import annotations 3 | 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("afip", "0012_optionaltype_optional_alter_code_in_generics"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="receiptentry", 16 | name="quantity", 17 | field=models.DecimalField( 18 | decimal_places=2, max_digits=15, verbose_name="quantity" 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /django_afip/migrations/0014_alter_pointofsales_blocked_alter_taxpayer_logo.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1 on 2024-08-08 22:04 2 | from __future__ import annotations 3 | 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("afip", "0013_alter_receiptentry_quantity"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="pointofsales", 16 | name="blocked", 17 | field=models.BooleanField(default=False, verbose_name="blocked"), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /django_afip/migrations/0015_alter_taxpayer_logo.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.1 on 2024-10-03 16:12 2 | from __future__ import annotations 3 | 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("afip", "0014_alter_pointofsales_blocked_alter_taxpayer_logo"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="taxpayer", 16 | name="logo", 17 | field=models.ImageField( 18 | blank=True, 19 | help_text="A logo to use when generating printable receipts.", 20 | null=True, 21 | upload_to="afip/taxpayers/logos/", 22 | verbose_name="logo", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /django_afip/migrations/0016_clientvatcondition_receipt_client_vat_condition.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.7 on 2025-03-21 14:36 2 | from __future__ import annotations 3 | 4 | import django.db.models.deletion 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("afip", "0015_alter_taxpayer_logo"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="ClientVatCondition", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("code", models.CharField(max_length=3, verbose_name="code")), 28 | ( 29 | "description", 30 | models.CharField(max_length=250, verbose_name="description"), 31 | ), 32 | ( 33 | "cmp_clase", 34 | models.CharField( 35 | help_text="The class of the client VAT condition.", 36 | max_length=10, 37 | verbose_name="cmp clase", 38 | ), 39 | ), 40 | ], 41 | options={ 42 | "verbose_name": "client VAT condition", 43 | "verbose_name_plural": "client VAT conditions", 44 | }, 45 | ), 46 | migrations.AddField( 47 | model_name="receipt", 48 | name="client_vat_condition", 49 | field=models.ForeignKey( 50 | blank=True, 51 | help_text="The client VAT condition of the recipient of this receipt.", 52 | null=True, 53 | on_delete=django.db.models.deletion.PROTECT, 54 | to="afip.clientvatcondition", 55 | verbose_name="client VAT condition", 56 | ), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /django_afip/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhyNotHugo/django-afip/07d50b6af2315121a54b090f7566f812d77cf47f/django_afip/migrations/__init__.py -------------------------------------------------------------------------------- /django_afip/parsers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import date 4 | from datetime import datetime 5 | 6 | from django_afip.clients import TZ_AR 7 | 8 | 9 | def parse_datetime(datestring: str) -> datetime: 10 | return datetime.strptime(datestring, "%Y%m%d%H%M%S").replace(tzinfo=TZ_AR) 11 | 12 | 13 | def parse_datetime_maybe(datestring: str | None) -> datetime | None: 14 | if datestring == "NULL" or datestring is None: 15 | return None 16 | return parse_datetime(datestring) 17 | 18 | 19 | def parse_date(datestring: str) -> date: 20 | return datetime.strptime(datestring, "%Y%m%d").date() 21 | 22 | 23 | def parse_date_maybe(datestring: str | None) -> date | None: 24 | if datestring == "NULL" or datestring is None: 25 | return None 26 | return parse_date(datestring) 27 | 28 | 29 | def parse_string(string: str) -> str: 30 | """Re-encodes strings from AFIP's weird encoding to UTF-8.""" 31 | try: 32 | return string.encode("latin-1").decode() 33 | except UnicodeDecodeError: 34 | # It looks like SOME errors are plain UTF-8 text. 35 | return string 36 | -------------------------------------------------------------------------------- /django_afip/pdf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import json 5 | import logging 6 | from decimal import Decimal 7 | from io import BytesIO 8 | from typing import TYPE_CHECKING 9 | from typing import TypedDict 10 | 11 | import qrcode 12 | from django.core.paginator import Paginator 13 | from django_renderpdf.helpers import render_pdf 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterable 17 | from typing import IO 18 | 19 | from PIL.Image import Image 20 | 21 | from django_afip.models import Receipt 22 | from django_afip.models import ReceiptEntry 23 | from django_afip.models import ReceiptPDF 24 | 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | class ReceiptQrCode: 30 | """A QR code for receipt 31 | 32 | See: https://www.afip.gob.ar/fe/qr/especificaciones.asp""" 33 | 34 | BASE_URL = "https://www.afip.gob.ar/fe/qr/?p=" 35 | 36 | def __init__(self, receipt: Receipt) -> None: 37 | self._receipt = receipt 38 | # The examples on the website say that "importe" and "ctz" are both "decimal" 39 | # type. JS/JSON has no decimal type. The examples use integeres. 40 | # 41 | # Using integers would drop cents, which would result in mismatching 42 | # information. Using strings would add quotes, which likely breaks. 43 | # 44 | # Using floats seems to be the only viable solution, and SHOULD be fine for 45 | # values in the range supported. 46 | self._data = { 47 | "ver": 1, 48 | "fecha": receipt.issued_date.strftime("%Y-%m-%d"), 49 | "cuit": receipt.point_of_sales.owner.cuit, 50 | "ptoVta": receipt.point_of_sales.number, 51 | "tipoCmp": int(receipt.receipt_type.code), 52 | "nroCmp": receipt.receipt_number, 53 | "importe": float(receipt.total_amount), 54 | "moneda": receipt.currency.code, 55 | "ctz": float(receipt.currency_quote), 56 | "tipoDocRec": int(receipt.document_type.code), 57 | "nroDocRec": receipt.document_number, 58 | "tipoCodAut": "E", # TODO: need to implement CAEA 59 | "codAut": int(receipt.validation.cae), 60 | } 61 | 62 | def as_png(self) -> Image: 63 | json_data = json.dumps(self._data) 64 | encoded_json = base64.b64encode(json_data.encode()).decode() 65 | url = f"{self.BASE_URL}{encoded_json}" 66 | 67 | qr = qrcode.QRCode(version=1, border=4) 68 | qr.add_data(url) 69 | qr.make() 70 | 71 | return qr.make_image() 72 | 73 | 74 | def get_encoded_qrcode(receipt_pdf: ReceiptPDF) -> str: 75 | """Return a QRCode encoded for embeding in HTML.""" 76 | 77 | img_data = BytesIO() 78 | qr_img = ReceiptQrCode(receipt_pdf.receipt).as_png() 79 | qr_img.save(img_data, format="PNG") 80 | 81 | return base64.b64encode(img_data.getvalue()).decode() 82 | 83 | 84 | # Note: When updating this, be sure to update the docstring of the method that uses 85 | # these below. 86 | TEMPLATE_NAMES = [ 87 | "receipts/{taxpayer}/pos_{point_of_sales}/code_{code}.html", 88 | "receipts/{taxpayer}/code_{code}.html", 89 | "receipts/code_{code}.html", 90 | "receipts/{code}.html", 91 | ] 92 | 93 | 94 | class EntriesForPage(TypedDict): 95 | previous_subtotal: Decimal 96 | subtotal: Decimal 97 | entries: Iterable[ReceiptEntry] 98 | 99 | 100 | def create_entries_context_for_render( 101 | paginator: Paginator, 102 | ) -> dict[int, EntriesForPage]: 103 | entries: dict[int, EntriesForPage] = {} 104 | subtotal = Decimal(0) 105 | for i in paginator.page_range: 106 | previous_subtotal = subtotal 107 | page = paginator.get_page(i) 108 | 109 | for entry in page.object_list: 110 | subtotal += entry.total_price 111 | 112 | entries[i] = { 113 | "previous_subtotal": previous_subtotal, 114 | "subtotal": subtotal, 115 | "entries": paginator.get_page(i).object_list, 116 | } 117 | return entries 118 | 119 | 120 | class PdfBuilder: 121 | """Builds PDF files for Receipts. 122 | 123 | Creating a new instance of a builder does nothing; use :meth:`~render_pdf` to 124 | actually render the file. 125 | 126 | This type can be subclassed to add custom behaviour or data into PDF files. 127 | """ 128 | 129 | def __init__(self, entries_per_page: int = 15) -> None: 130 | self.entries_per_page = entries_per_page 131 | 132 | def get_template_names(self, receipt: Receipt) -> list[str]: 133 | """Return the templates use to render the Receipt PDF. 134 | 135 | Template discovery tries to find any of the below receipts:: 136 | 137 | receipts/{taxpayer}/pos_{point_of_sales}/code_{code}.html 138 | receipts/{taxpayer}/code_{code}.html 139 | receipts/code_{code}.html 140 | receipts/{code}.html 141 | 142 | To override, for example, the "Factura C" template for point of sales 0002 for 143 | Taxpayer 20-32964233-0, use:: 144 | 145 | receipts/20329642330/pos_2/code_6.html 146 | """ 147 | return [ 148 | template.format( 149 | taxpayer=receipt.point_of_sales.owner.cuit, 150 | point_of_sales=receipt.point_of_sales.number, 151 | code=receipt.receipt_type.code, 152 | ) 153 | for template in TEMPLATE_NAMES 154 | ] 155 | 156 | def get_context(self, receipt: Receipt) -> dict: 157 | """Returns the context used to render the PDF file.""" 158 | from django_afip.models import Receipt 159 | from django_afip.models import ReceiptPDF 160 | 161 | context: dict = {} 162 | 163 | receipt_pdf = ( 164 | ReceiptPDF.objects.select_related( 165 | "receipt", 166 | "receipt__receipt_type", 167 | "receipt__document_type", 168 | "receipt__validation", 169 | "receipt__point_of_sales", 170 | "receipt__point_of_sales__owner", 171 | ) 172 | .prefetch_related( 173 | "receipt__entries", 174 | ) 175 | .get(receipt=receipt) 176 | ) 177 | 178 | # Prefetch required data in a single query: 179 | receipt_pdf.receipt = ( 180 | Receipt.objects.select_related( 181 | "receipt_type", 182 | "document_type", 183 | "validation", 184 | "point_of_sales", 185 | "point_of_sales__owner", 186 | ) 187 | .prefetch_related( 188 | "entries", 189 | ) 190 | .get( 191 | pk=receipt_pdf.receipt_id, 192 | ) 193 | ) 194 | taxpayer = receipt_pdf.receipt.point_of_sales.owner 195 | entries = receipt_pdf.receipt.entries.order_by("id") 196 | paginator = Paginator(entries, self.entries_per_page) 197 | 198 | context["entries"] = create_entries_context_for_render(paginator) 199 | context["pdf"] = receipt_pdf 200 | context["taxpayer"] = taxpayer 201 | context["qrcode"] = get_encoded_qrcode(receipt_pdf) 202 | 203 | return context 204 | 205 | def render_pdf(self, receipt: Receipt, file_: IO) -> None: 206 | """Renders the PDF into ``file_``.""" 207 | render_pdf( 208 | template=self.get_template_names(receipt), 209 | file_=file_, 210 | context=self.get_context(receipt), 211 | ) 212 | -------------------------------------------------------------------------------- /django_afip/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhyNotHugo/django-afip/07d50b6af2315121a54b090f7566f812d77cf47f/django_afip/py.typed -------------------------------------------------------------------------------- /django_afip/serializers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from typing import TYPE_CHECKING 5 | 6 | from django.utils.functional import LazyObject 7 | 8 | from django_afip.clients import get_client 9 | 10 | if TYPE_CHECKING: 11 | from datetime import date 12 | from datetime import datetime 13 | 14 | from django.db.models import QuerySet 15 | 16 | from django_afip.models import AuthTicket 17 | from django_afip.models import Optional 18 | from django_afip.models import Receipt 19 | from django_afip.models import Tax 20 | from django_afip.models import Vat 21 | 22 | 23 | class _LazyFactory(LazyObject): 24 | """A lazy-initialised factory for WSDL objects.""" 25 | 26 | def _setup(self) -> None: 27 | self._wrapped = get_client("wsfe").type_factory("ns0") 28 | 29 | 30 | f = _LazyFactory() 31 | 32 | 33 | def serialize_datetime(datetime: datetime) -> str: 34 | """ 35 | "Another date formatting function?" you're thinking, eh? Well, this 36 | actually formats dates in the *exact* format the AFIP's WS expects it, 37 | which is almost like ISO8601. 38 | 39 | Note that .isoformat() works fine on production servers, but not on the 40 | sandbox ones. 41 | """ 42 | return datetime.strftime("%Y-%m-%dT%H:%M:%S-00:00") 43 | 44 | 45 | def serialize_date(date: date) -> str: 46 | return date.strftime("%Y%m%d") 47 | 48 | 49 | @typing.no_type_check # zeep's dynamic types cannot be type-checked 50 | def serialize_ticket(ticket: AuthTicket): # noqa: ANN201 51 | return f.FEAuthRequest( 52 | Token=ticket.token, 53 | Sign=ticket.signature, 54 | Cuit=ticket.owner.cuit, 55 | ) 56 | 57 | 58 | @typing.no_type_check # zeep's dynamic types cannot be type-checked 59 | def serialize_multiple_receipts(receipts: QuerySet[Receipt]): # noqa: ANN201 60 | receipts = receipts.all().order_by("receipt_number") 61 | 62 | first = receipts.first() 63 | receipts = [serialize_receipt(receipt) for receipt in receipts] 64 | 65 | return f.FECAERequest( 66 | FeCabReq=f.FECAECabRequest( 67 | CantReg=len(receipts), 68 | PtoVta=first.point_of_sales.number, 69 | CbteTipo=first.receipt_type.code, 70 | ), 71 | FeDetReq=f.ArrayOfFECAEDetRequest(receipts), 72 | ) 73 | 74 | 75 | @typing.no_type_check # zeep's dynamic types cannot be type-checked 76 | def serialize_receipt(receipt: Receipt): # noqa: ANN201 77 | taxes = receipt.taxes.all() 78 | vats = receipt.vat.all() 79 | optionals = receipt.optionals.all() 80 | 81 | serialized = f.FECAEDetRequest( 82 | Concepto=receipt.concept.code, 83 | DocTipo=receipt.document_type.code, 84 | DocNro=receipt.document_number, 85 | # TODO: Check that this is not None!, 86 | CbteDesde=receipt.receipt_number, 87 | CbteHasta=receipt.receipt_number, 88 | CbteFch=serialize_date(receipt.issued_date), 89 | ImpTotal=receipt.total_amount, 90 | ImpTotConc=receipt.net_untaxed, 91 | ImpNeto=receipt.net_taxed, 92 | ImpOpEx=receipt.exempt_amount, 93 | ImpIVA=sum(vat.amount for vat in vats), 94 | ImpTrib=sum(tax.amount for tax in taxes), 95 | MonId=receipt.currency.code, 96 | MonCotiz=receipt.currency_quote, 97 | ) 98 | if int(receipt.concept.code) in (2, 3): 99 | serialized.FchServDesde = serialize_date(receipt.service_start) 100 | serialized.FchServHasta = serialize_date(receipt.service_end) 101 | 102 | if receipt.expiration_date is not None: 103 | serialized.FchVtoPago = serialize_date(receipt.expiration_date) 104 | 105 | if taxes: 106 | serialized.Tributos = f.ArrayOfTributo([serialize_tax(tax) for tax in taxes]) 107 | 108 | if vats: 109 | serialized.Iva = f.ArrayOfAlicIva([serialize_vat(vat) for vat in vats]) 110 | 111 | if optionals: 112 | serialized.Opcionales = f.ArrayOfOpcional( 113 | [serialize_optional(optional) for optional in optionals] 114 | ) 115 | 116 | if receipt.client_vat_condition: 117 | serialized.CondicionIVAReceptorId = receipt.client_vat_condition.code 118 | 119 | related_receipts = receipt.related_receipts.all() 120 | if related_receipts: 121 | serialized.CbtesAsoc = f.ArrayOfCbteAsoc( 122 | [ 123 | f.CbteAsoc( 124 | r.receipt_type.code, 125 | r.point_of_sales.number, 126 | r.receipt_number, 127 | r.point_of_sales.owner.cuit, 128 | serialize_date(r.issued_date), 129 | ) 130 | for r in related_receipts 131 | ] 132 | ) 133 | 134 | return serialized 135 | 136 | 137 | @typing.no_type_check # zeep's dynamic types cannot be type-checked 138 | def serialize_tax(tax: Tax): # noqa: ANN201 139 | return f.Tributo( 140 | Id=tax.tax_type.code, 141 | Desc=tax.description, 142 | BaseImp=tax.base_amount, 143 | Alic=tax.aliquot, 144 | Importe=tax.amount, 145 | ) 146 | 147 | 148 | @typing.no_type_check # zeep's dynamic types cannot be type-checked 149 | def serialize_vat(vat: Vat): # noqa: ANN201 150 | return f.AlicIva( 151 | Id=vat.vat_type.code, 152 | BaseImp=vat.base_amount, 153 | Importe=vat.amount, 154 | ) 155 | 156 | 157 | @typing.no_type_check # zeep's dynamic types cannot be type-checked 158 | def serialize_optional(optional: Optional): # noqa: ANN201 159 | return f.Opcional( 160 | Id=optional.optional_type.code, 161 | Valor=optional.value, 162 | ) 163 | 164 | 165 | def serialize_receipt_data( # noqa: ANN201 166 | receipt_type: str, 167 | receipt_number: int, 168 | point_of_sales: int, 169 | ): 170 | # TYPING: Types for zeep's factories are not inferred. 171 | return f.FECompConsultaReq( # type: ignore[attr-defined] 172 | CbteTipo=receipt_type, CbteNro=receipt_number, PtoVta=point_of_sales 173 | ) 174 | -------------------------------------------------------------------------------- /django_afip/signals.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from django.db.models.signals import post_save 6 | from django.db.models.signals import pre_save 7 | from django.dispatch import receiver 8 | 9 | from django_afip import models 10 | 11 | if TYPE_CHECKING: 12 | from django.db.models import Model 13 | 14 | 15 | @receiver(pre_save, sender=models.TaxPayer) 16 | def update_certificate_expiration( 17 | sender: type[Model], 18 | instance: models.TaxPayer, 19 | **kwargs, 20 | ) -> None: 21 | if instance.certificate: 22 | instance.certificate_expiration = instance.get_certificate_expiration() 23 | 24 | 25 | FILE_FIELDS = ["certificate", "logo"] 26 | 27 | 28 | # Store old files before saving. 29 | @receiver(pre_save, sender=models.TaxPayer) 30 | def store_old_files( 31 | sender: type[models.TaxPayer], 32 | instance: models.TaxPayer, 33 | **kwargs, 34 | ) -> None: 35 | if instance.pk: 36 | try: 37 | old_instance = sender.objects.get(pk=instance.pk) 38 | # Save the reference of the old files in the model. 39 | instance._old_files = { 40 | field: getattr(old_instance, field) for field in FILE_FIELDS 41 | } 42 | except sender.DoesNotExist: 43 | instance._old_files = {} 44 | 45 | 46 | # Delete old files after saving. 47 | @receiver(post_save, sender=models.TaxPayer) 48 | def delete_file_taxpayer( 49 | sender: type[models.TaxPayer], 50 | instance: models.TaxPayer, 51 | **kwargs, 52 | ) -> None: 53 | if not instance.pk: 54 | return # The instance is new, there are no old files to delete. 55 | 56 | old_files = getattr(instance, "_old_files", {}) 57 | 58 | for field_name in FILE_FIELDS: 59 | old_file = old_files.get(field_name) 60 | new_file = getattr(instance, field_name) 61 | 62 | if old_file and old_file != new_file: 63 | # Delete the old file from storage. 64 | old_file.delete(save=False) 65 | -------------------------------------------------------------------------------- /django_afip/static/receipts/receipt.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: "Open Sans",sans-serif; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | @media print { 8 | html, body { 9 | /* A4: 210mm x 297mm */ 10 | font-size: 0.9em; 11 | margin: -4mm; 12 | /* width: 187mm; */ 13 | 14 | /* with -7 margin */ 15 | /* width: 198mm; */ 16 | /* height: 280mm; */ 17 | } 18 | } 19 | 20 | .receipt { 21 | margin-left: auto; 22 | margin-right: auto; 23 | margin-top: 20px; 24 | max-width: 960px; 25 | position: relative; 26 | width: 100%; 27 | } 28 | 29 | .taxpayer-details:after { 30 | clear: both; 31 | content: ''; 32 | display: block; 33 | } 34 | 35 | .taxpayer-details > address { 36 | float: left; 37 | text-align: center; 38 | width: 40%; 39 | } 40 | 41 | .taxpayer-details img { 42 | margin-bottom: 1em; 43 | max-height: 40px; 44 | max-width: 180px; 45 | } 46 | 47 | 48 | .taxpayer-details > .receipt-details { 49 | float: right; 50 | text-align: right; 51 | width: 40%; 52 | } 53 | 54 | .receipt-type { 55 | background-color: #DDD; 56 | font-size: 1.8em; 57 | height: 2.6em; 58 | left: 50%; 59 | margin-left: -1.5em; 60 | position: absolute; 61 | text-align: center; 62 | width: 3em; 63 | } 64 | 65 | .receipt-type .identifier { 66 | font-weight: bold; 67 | margin-top: .3em; 68 | } 69 | 70 | .receipt-type .code { 71 | font-size: 0.3em; 72 | } 73 | 74 | .receipt-number { 75 | text-align: right; 76 | } 77 | 78 | .receipt-details .receipt-type-description { 79 | font-size: 1.3em; 80 | font-weight: bold; 81 | } 82 | 83 | address { 84 | float: left; 85 | font-style: normal; 86 | } 87 | 88 | .sale-conditions { 89 | float: right; 90 | } 91 | 92 | .service-dates:after { 93 | clear: both; 94 | content: ''; 95 | display: block; 96 | } 97 | 98 | .service-dates > div { 99 | float: left; 100 | width: 50%; 101 | } 102 | 103 | .service-dates .expiration { 104 | text-align: right; 105 | } 106 | 107 | table { 108 | border-collapse: collapse; 109 | border-spacing: 0; 110 | margin-bottom: 30px; 111 | width: 100%; 112 | } 113 | 114 | th { 115 | text-align: left; 116 | } 117 | 118 | th, td { 119 | padding: 8px; 120 | text-align: center; 121 | } 122 | 123 | th { 124 | border-bottom: 2px solid #DDD; 125 | } 126 | 127 | tbody > tr:nth-of-type(2n+1) { 128 | background-color: #F9F9F9; 129 | } 130 | 131 | td { 132 | border-bottom: 1px solid #DDD; 133 | } 134 | 135 | tfoot { 136 | font-weight: bold; 137 | } 138 | 139 | hr { 140 | background-color: #DDD; 141 | border: 0; 142 | height: 1px; 143 | } 144 | 145 | a { 146 | color: #00E; 147 | } 148 | 149 | .cae strong:nth-child(2) { 150 | margin-left: 20px; 151 | } 152 | 153 | .qrcode { 154 | text-align: center; 155 | float: left; 156 | } 157 | 158 | .qrcode img { 159 | display: block; 160 | width: 120px; 161 | } 162 | -------------------------------------------------------------------------------- /django_afip/templates/receipts/code_11.html: -------------------------------------------------------------------------------- 1 | code_6.html -------------------------------------------------------------------------------- /django_afip/templates/receipts/code_13.html: -------------------------------------------------------------------------------- 1 | code_6.html -------------------------------------------------------------------------------- /django_afip/templates/receipts/code_3.html: -------------------------------------------------------------------------------- 1 | code_6.html -------------------------------------------------------------------------------- /django_afip/templates/receipts/code_6.html: -------------------------------------------------------------------------------- 1 | {% load static django_afip %} 2 | 3 | {# pdf is a ReceiptPDF object #} 4 | {# taxpayer is a TaxPayer object #} 5 | {# TODO: ¿How do we deal with multiple entries with different VAT types? #} 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% for page,page_entries in entries.items %} 13 |
14 | 15 | 82 | 83 |
84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | {% if page != 1 %} 95 | 96 | 97 | 98 | 99 | 100 | 101 | {% endif %} 102 | 103 | {% for entry in page_entries.entries %} 104 | 105 | 106 | 107 | 108 | 109 | 110 | {% endfor %} 111 | 112 | 113 | 114 | 115 | 116 | {% if forloop.last %} 117 | 118 | 119 | {% else %} 120 | 121 | 122 | {% endif %} 123 | 124 | 125 | 126 |
DescripciónCantidadPrecio UnitarioMonto
Subtotal anterior{{page_entries.previous_subtotal}}
{{ entry.description }}{{ entry.quantity }}{{ entry.unit_price }}{{ entry.total_price|floatformat:2 }}
Total{{pdf.receipt.total_amount}} Subtotal{{ page_entries.subtotal|floatformat:2 }}
127 | 128 |
129 |
130 | 131 |
132 | 133 |

134 | CAE 135 | {{ pdf.receipt.validation.cae }} 136 | Vto CAE 137 | {{ pdf.receipt.validation.cae_expiration }} 138 |

139 | 140 | Consultas de validez: 141 | 142 | https://www.afip.gob.ar/genericos/consultacae/ 143 | 144 |
145 | Teléfono Gratuito CABA, Área de Defensa y Protección al Consumidor. 146 | Tel 147 147 |
148 | {% with dict_keys=entries.keys %} 149 | Hoja {{page}} de {{ dict_keys|length }} 150 | {% endwith %} 151 |
152 |
153 | {% endfor %} 154 | 155 | 156 | 157 | 158 | -------------------------------------------------------------------------------- /django_afip/templates/receipts/code_8.html: -------------------------------------------------------------------------------- 1 | code_6.html -------------------------------------------------------------------------------- /django_afip/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhyNotHugo/django-afip/07d50b6af2315121a54b090f7566f812d77cf47f/django_afip/templatetags/__init__.py -------------------------------------------------------------------------------- /django_afip/templatetags/django_afip.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | from django import template 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.filter 11 | def format_cuit(cuit: str | int) -> str | int: 12 | numbers = re.sub("[^\\d]", "", str(cuit)) 13 | if len(numbers) != 11: 14 | return cuit 15 | return f"{numbers[0:2]}-{numbers[2:10]}-{numbers[10:11]}" 16 | -------------------------------------------------------------------------------- /django_afip/testing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhyNotHugo/django-afip/07d50b6af2315121a54b090f7566f812d77cf47f/django_afip/testing/__init__.py -------------------------------------------------------------------------------- /django_afip/testing/test.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDTDCCAjSgAwIBAgIIQaJsFF1Mob4wDQYJKoZIhvcNAQENBQAwODEaMBgGA1UEAwwRQ29tcHV0 3 | YWRvcmVzIFRlc3QxDTALBgNVBAoMBEFGSVAxCzAJBgNVBAYTAkFSMB4XDTI0MDkwNTA4MTcxN1oX 4 | DTI2MDkwNTA4MTcxN1owMjEVMBMGA1UEAwwMdGVzdDIwMjQwOTA1MRkwFwYDVQQFExBDVUlUIDIw 5 | MzI5NjQyMzMwMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzzWvKBZV4ODroB2pVqf9 6 | aZeVwTlgXBhR/31JclOnLRDf1wywsW7tIFIoMcB5d7ruYP0QJauPmM4txKxmn2XfaaxciXv8Xk3n 7 | HQ3JX43uvYSHEa0N15NHfetE3JxrVmDgOlqSZ1e3v6xBlGqRKGZDoy6E3rF7jVfjUJxgllTzh4/z 8 | c7sfD3tdixpEIcif5WjJVVDkJJdIhYHlfHLhMBf5hTYE+srlUoBB0FqWUMyeRw4fnxP4UsWGgH9k 9 | HMmd+HfotsISKuc2W5/Kqoq74PQF5ho1QtNl2ke2YxrPnBQLaZqzYfQsjGouloicEtIJSZ/EJn6Z 10 | f0JjIKD8GSzUWyrqrwIDAQABo2AwXjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFLOy0//96bre 11 | 3o2vESGc1iB98k9vMB0GA1UdDgQWBBQ3Dz5fQvGl7w2wZdVFhRZ8zNe75zAOBgNVHQ8BAf8EBAMC 12 | BeAwDQYJKoZIhvcNAQENBQADggEBAKYZwZAD6Xmsk8supalnT3dXu6Lks/L53xcnVOKA7uG+gQif 13 | //ucMzd5jbWUTFITug1pmUHXrZ+eYxIbZbKgyFfF4WRrh7hpKO8uPWtY2lpZxBOfvxu4TihI5UBG 14 | m+D/gEVh4so/g7mBdVSTHfx51bXUCC6UPz+KsCvXnwxZYtahbHOfPOS7uF/I8QCcFYnORQeYetr6 15 | mAQhS7q5w448d5+X9wXkqznhW8CeFs5DgKg+qtYhZUjRGCQleV15p3vv6xkfuH3erjOHxgO10z71 16 | OSXczkCFQzEjDD5VyZlWHJovtUf3EJeWEpXZz5uzdBVrbiuo4gZ+Ft58wgZYaAPJsO8= 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /django_afip/testing/test.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEAzzWvKBZV4ODroB2pVqf9aZeVwTlgXBhR/31JclOnLRDf1wyw 3 | sW7tIFIoMcB5d7ruYP0QJauPmM4txKxmn2XfaaxciXv8Xk3nHQ3JX43uvYSHEa0N 4 | 15NHfetE3JxrVmDgOlqSZ1e3v6xBlGqRKGZDoy6E3rF7jVfjUJxgllTzh4/zc7sf 5 | D3tdixpEIcif5WjJVVDkJJdIhYHlfHLhMBf5hTYE+srlUoBB0FqWUMyeRw4fnxP4 6 | UsWGgH9kHMmd+HfotsISKuc2W5/Kqoq74PQF5ho1QtNl2ke2YxrPnBQLaZqzYfQs 7 | jGouloicEtIJSZ/EJn6Zf0JjIKD8GSzUWyrqrwIDAQABAoIBAQDC8TXF2CWZeOIU 8 | 9HLxVQCjy/oXHHzciAD1BhEIX01Dp8F/l6/QbpTmuISaMgPVMee9FbiQSDTDxQ3o 9 | H244/mXPDFrO+fe3XUFW4zaWEHkQkdNlPNNuAmDbRXG2v+54CJJMNBPS6AK2cEAO 10 | eqfwQy3VIxncTUv48TuqBkUUif3HhWRaqAgHwzef293lExEe9YXFYFIQsaTLEedr 11 | J4rfeQoTIcLcdJQ9+srSqu4DqcNj9hhxPaKCUF/wc7+3vHi0+38BKwwPNiQQ+05M 12 | LKQ46oVCEqjt3xki0H91aEIOhBv/k/hq1dx3XFycs2hvEvgL7S/m/rKCdrgzOUBD 13 | ap3RQ8VRAoGBAOgjOQoM40aln7VJHD9RKPlM1vry8kkEao66RzwX2oFonSV2Cp9i 14 | EKb2Ir5dKmLVMIZ74y/g0kTk7clrGNUtw2HObBrYPi0JfPusNKg8AGTlf7l//Zcg 15 | Ai4rDIiA1ISXssJVtOpXrd3IYzqsJ5J448wtg2ma/uXd5ttbErSCLJF5AoGBAOSC 16 | edM1xll08vrC5XEKq4VVuU1GEtZWnhCwZVuM2oZSw84KuM4Uvr0c4jsIWVblEseO 17 | MhVnFszu2MXl19PzVOkbo0JC5PHKeu8EEgYV8xFS8kCq5hZmXHVU96OdLiLLnJlw 18 | /5lPV6RkoqAD8BnbS8UResv5GgD+4JgcIPhXgbtnAoGBAL+7p/cHIglN7xWa5zvc 19 | 2wzeTIpFc5yfiyuL7B5UTWOpdnJkhu2R39MnZRb6eHHdSBEr7j+zX7kLpONCE+av 20 | v7re9idOCDzGo3Zzi6KQvHqZm98pOdlC1MoxQE7WqbFCYqFkjOMpvC98vYjOfHjc 21 | ZLpVtT7aiEJv/6eaF18ETa9xAoGAD7RH2xQfBZbb/A/Y5OPu1cMGcSEXulNJmawF 22 | yzzq58BYZJioCsGyOhz6D0SLn0Uu/TfwiTgEgSEJFNCu/IoEk+CqX6tpQJTBzhth 23 | gbmQcuhYbclQ9skiIY4tVrk+qnWD1afGaSriwxGHe6fJoH1Jv8lrvwjnmJnrpYiJ 24 | W4foCpECgYEAz7lVRKU3JJ+2iiZOJrjwWZP+V0la/7lGWIQM4KC2R4sIBSXcWO/D 25 | pbygObz/AjJPTtLQOs3k8pd8RzL1/hhjYkJ3vFaHZL5cvojgBKqD8968PkqQPw4T 26 | RK+ytxODpEJ2hPrVMQo7bV9eguHmkset+GPyTQIfsYA0WpQd8ulNAGE= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /django_afip/testing/test2.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDTTCCAjWgAwIBAgIIUa+IttT/o5QwDQYJKoZIhvcNAQENBQAwODEaMBgGA1UEAwwRQ29tcHV0 3 | YWRvcmVzIFRlc3QxDTALBgNVBAoMBEFGSVAxCzAJBgNVBAYTAkFSMB4XDTI0MDkwNTA4MjA0NVoX 4 | DTI2MDkwNTA4MjA0NVowMzEWMBQGA1UEAwwNdGVzdDIwMjQwOTA1YjEZMBcGA1UEBRMQQ1VJVCAy 5 | MDMyOTY0MjMzMDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKuo4AqtLjSgB700Zofj 6 | FEjDIM/mOB/wdDQXHMI/mssDBeMB8z4HAZHPsQJWr19RJdN2ok/OK7Fnl/zN+3cmFfQooBPzfo8A 7 | ldVsG3+B2R6udx3eTZmySVC4WlYvzvx69/jeZ6vYrD7Xd4BwwqzK3FaTVxqMWXxQCQxzW7IlMqdO 8 | omTfYA9ekz+tLQL5j8jrSD8Obs5AERBEmYEUPPXxzQD/s8ASvW1Mz0o823LH17tXW7TVUx3fXudA 9 | V6ksmz1UIJQvUtp54OY/Lrqx5EQk54bphuRayewRzmUIuOR/0dt3Gp6GMtcUN2/cNvNO/RHzZvR3 10 | XSelQvRxR6WDwcwmLmsCAwEAAaNgMF4wDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBSzstP//em6 11 | 3t6NrxEhnNYgffJPbzAdBgNVHQ4EFgQUcjgJ25O5pAKwjCnVay+gts3dhHEwDgYDVR0PAQH/BAQD 12 | AgXgMA0GCSqGSIb3DQEBDQUAA4IBAQBMw1JTSKui2sqdfNw3p/3VpPXa83AL5RnCGpBtaFKl1G29 13 | OtcScMbSwG0oc+rqBoPkhBtWsDfht7aoeQkqaEOjtIwqgo2YjzZCM44QORZeb8wjmGoZEMrUiUKq 14 | htMotyqxygkey3Y3EUtZ/gcIV1Wz9KyWleItA+psbs7LnjRzW78JJouuhdCabHrih9mdxPFR1zAH 15 | 48TdechfToBWvxx7bxu8YKmF13yXofV0o0VtTwDJdcis0znkkZ6PhZuxEsUrHTwfFpykijdT+IaQ 16 | x3JbGzI+2HG59Te8K65pV75Mx0skyRV39dp27OZ9+qqB5KQm60YUHR6pSZThNR2ABZT2 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /django_afip/testing/test2.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEAq6jgCq0uNKAHvTRmh+MUSMMgz+Y4H/B0NBccwj+aywMF4wHz 3 | PgcBkc+xAlavX1El03aiT84rsWeX/M37dyYV9CigE/N+jwCV1Wwbf4HZHq53Hd5N 4 | mbJJULhaVi/O/Hr3+N5nq9isPtd3gHDCrMrcVpNXGoxZfFAJDHNbsiUyp06iZN9g 5 | D16TP60tAvmPyOtIPw5uzkAREESZgRQ89fHNAP+zwBK9bUzPSjzbcsfXu1dbtNVT 6 | Hd9e50BXqSybPVQglC9S2nng5j8uurHkRCTnhumG5FrJ7BHOZQi45H/R23canoYy 7 | 1xQ3b9w28079EfNm9HddJ6VC9HFHpYPBzCYuawIDAQABAoIBAGjBZAff64Sd7Fsg 8 | cTmX8Db/LTTyL2n+WUu4lwpLunENZThFZmWB7QlIj6L3t4oZgVXs3dyJ2swmhe/2 9 | 2/C4Q0l+yUGjpKYsG0Pk91r2qMM6gOKvWPfkfy1Nc4OJJW2atV0gjG+oiGTJJNoy 10 | 9fpwycnjnJJM+AcO6Ja6h0jNvn2uVRvCuoCt8Y+mUX0s6Dl91zdCejzk18en+hAm 11 | PGMbgRzBXExvml/AvpYFzkO83zgnO3+d6HwvVmrOEqVakBe7Xk+00BZysdgwHsK1 12 | 4vVk4qseKIjJPfwKFtCZT6/e1Eb6YkP64qC9C3puW/H8C/OKwOPevilAr8u4qWCC 13 | 7Oa0mvECgYEA2Fc3pRqBwdILKXvy0t6BkHNGqweWtzB9bXak/OC0beYfD78elq+n 14 | JT2tfpr3Z5gEWmmxpVE1OZviWES6Cf9ByxxP8MxLBTfvh0R9Wl3oznMUDBO4Fk7f 15 | Mzji8sGVkMStvE3VOAmdELV2OTXm2s0JTYL/AghQ+SFCw9EWOK7L3H8CgYEAyyDL 16 | jGMLvE7EqUo6Oypoyqv7eZSCbc20CPMr6+1mni2q7gndTrmvJfd8THwu/+wu+EnP 17 | 0BhgVrjBprrdlQequkbIZ6eSFX0dFJsIPwNB+UB7UWuarsRIDoNq3TXvdrRmTJhS 18 | 0VOoQhBGVGGNlHPAjeyGSMOzUrg7EQ2WJaVm6BUCgYBkx+ySK1D7O6AbiTRLa4As 19 | DKeFERny6NHlZnnhm8Qx1hvuN/hF9joFLUBfVE2gor6UZ9xryPLkjWvZ9to7wf+i 20 | YOQUpvbjzXT2LL/AkzLayd0y6xS8v61WrU98CxZjxFuy9wc2/bN/jykt6aBLmWyW 21 | AUpOZhVimU4C7qpNaZBqfQKBgA3ZKodjqUUpSZcRDG9EMOjAWOCtE0dRItkJWxE2 22 | mixmiKS533CikCJSgRLl1H52J62duqFBSDAhYHJxgvHKGAWjFb6bWgZFBVqGR0Wk 23 | fzbzAnVGlMEdeJwksYBrUOwS2HaYW+0RewMmAOV30SMx0Qrb+Tu0u+ED1mMPPhFK 24 | +X0RAoGAcVcubgljKfDfzjEoBISWqIa5c0Leox83MWgzfOYEUvTB5wSh+22zgGXL 25 | HzYcglxuCD2VFlEafaX9gRE9DHHNHqHikHsPNeIIu7hqz5FQZMznk3Az4ZawnFdz 26 | hr0LfJMkFzyGQ2lByKwAQMoBpdRVJ+RFta65+NOUrSF+SehMQOk= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /django_afip/testing/test_expired.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIESzCCAzOgAwIBAgIIY5BN/5iDCgYwDQYJKoZIhvcNAQEFBQAwQzElMCMGA1UE 3 | AwwcQUZJUCBUZXN0aW5nIENvbXB1dGFkb3JlcyBDQTENMAsGA1UECgwEQUZJUDEL 4 | MAkGA1UEBhMCQVIwHhcNMTQwMzE4MTMzMTI3WhcNMTYxMjEyMTMzMTI3WjBlMRww 5 | GgYDVQQDDBNoeXBlcmlvbi5iYXJyZXJhLmlvMRkwFwYDVQQFExBDVUlUIDIwMzI5 6 | NjQyMzMwMR0wGwYDVQQKDBRIdWdvIE9zdmFsZG8gQmFycmVyYTELMAkGA1UEBhMC 7 | QVIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFMQPvyBUmg1sYxjXq 8 | MRKSjDfPGDdKmZJbk0b01m5C1g6/78jI/XT0XslpA7TVhXLYe9uc7R9SEQoZ22b/ 9 | Huj8wopPWNwh6vLMSGBmwWOfcDW98VKqJS+V8qyaThfO6wEIqfxMluisOdm7S+gV 10 | MqIf87sjTcFrXcbQ8oQ8T7j5Ee9M6wnzJ+LqwgvUA1Gtj1q+bu2alfbroIzOnOBD 11 | LE09RA+IfJHpThszY/VLi045fPeHrINkMjTA6gO5N1v9hqFMciDeLUKzOk+VkuIk 12 | JuEYaytrdV81U24KawR2axmz/vcIKG3THtvkdr4OzwqxfnYg2CpvbxoXunTNa8P0 13 | XGVfAgMBAAGjggEfMIIBGzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIF4DAd 14 | BgNVHQ4EFgQULl16R0BgNvyq8o2PRbEqld0q/HYwHwYDVR0jBBgwFoAURHTutJwm 15 | 31bhwQ3rVwuQGTY9lgEwgboGA1UdIASBsjCBrzCBrAYOKwYBBAGBu2MBAgECAQEw 16 | gZkwgZYGCCsGAQUFBwICMIGJHoGGAEMAZQByAHQAaQBmAGkAYwBhAGQAbwAgAHAA 17 | YQByAGEAIABjAG8AbQBwAHUAdABhAGQAbwByAGUAcwAgAHMAbwBsAG8AIAB2AGEA 18 | bABpAGQAbwAgAGUAbgAgAGUAbgB0AG8AcgBuAG8AcwAgAGQAZQAgAGQAZQBzAGEA 19 | cgByAG8AbABsAG8wDQYJKoZIhvcNAQEFBQADggEBAA9TOkwEUDPRWiUehfEVStIu 20 | uf4LjCe6TQ5SiVpcy4lj2qGTEVk5xaOjXclRvlRg2tYBtUkvmszcCXRwHGO5ELyh 21 | Pz0o7USUv3xFWdKuHo9lHiUvpLfIwRA6gvBDCTtu1PBW1Eh9S/FadOGvCob4h3pg 22 | i4fE+mA5idzr+2mkLofID18Q8uYCJcMCaIU3QadP9YYnjfhr1uGPo7UhH92WFp3K 23 | C3zAI/9tOLauM8IfSyfWbQAajekcnyeX7FbS1AEGIs07KYpTH+pZ4Ec3KafdEnsl 24 | VSNKajQx7CUQcSOPZgAD0Sk4ipmGYN3WoNGcHg2SS6b46emlJBEOlXZ+Sdp1i6I= 25 | -----END CERTIFICATE----- 26 | -------------------------------------------------------------------------------- /django_afip/testing/test_expired.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAxTED78gVJoNbGMY16jESkow3zxg3SpmSW5NG9NZuQtYOv+/I 3 | yP109F7JaQO01YVy2HvbnO0fUhEKGdtm/x7o/MKKT1jcIeryzEhgZsFjn3A1vfFS 4 | qiUvlfKsmk4XzusBCKn8TJborDnZu0voFTKiH/O7I03Ba13G0PKEPE+4+RHvTOsJ 5 | 8yfi6sIL1ANRrY9avm7tmpX266CMzpzgQyxNPUQPiHyR6U4bM2P1S4tOOXz3h6yD 6 | ZDI0wOoDuTdb/YahTHIg3i1CszpPlZLiJCbhGGsra3VfNVNuCmsEdmsZs/73CCht 7 | 0x7b5Ha+Ds8KsX52INgqb28aF7p0zWvD9FxlXwIDAQABAoIBAQC/rqfe/CXjLCjv 8 | ai2am10sa3UMsMN+ls34iq+c7Jx+B8qKtTH+frKjoUgz9tBgBrreyXdvkeiyQ7IH 9 | 9IioUom0uf7sprpFljeycGCVQkPvBoqXOvBaSI97OUyjKmpmT3bPzz1bzg4a7JlE 10 | QcM2Z+PFH424AauVDLFpB3WXkv+BzD+IE8OCAPFvtFS+3NRQ98MjlccGish6lOy+ 11 | PkcBkltLa1X+X7ybGmeppRy1ZRqYPUGj4fEDgGlM1X2BPJInrotPeAIRu8hx3a54 12 | 63SpYzh1O/SU38wv0M7EiL6ws6Kkhy8ZrLb1/CwjipdzJ3PHu/ElDMteRvscA11q 13 | mNQ+T9GhAoGBAPxfEIL8MzW1TGxLQXm4Y6eXGwNX6FucwJxauUNmWXukEX2UaMzx 14 | sSB4hMUF/sJdwK9nGSZcnzzurwHAPWH8O93/QDZOdInqgwrhlOhrYoYmtcdhafJo 15 | 8uHNZTsUJY6Vb58j5A9NT9gDmS1GkazhkPL0KSyO6SUjTYt0/iuepfG3AoGBAMgG 16 | 195gCWPWUcCqZsXDTzC/yuIV69tGyLL/afobh7XS4EAVHjrC0IqM6eJgNU4736s1 17 | uPLOz8e7N7YRqFT7YdEb4hht3NS61et5jENFseMUaQpwuQjFvBHlHeJrzViM6FRg 18 | RwjxWJtRt4wEbr/j2Cmt8Quj1cgxdLWo/0sBzImZAoGAJvN5Ne487SYtRG2dDm9S 19 | GjStO6feuf0IkVlDTM7IMtgQxwQX5MHM58kSHOKe/lq/+ZJ9BDm5bgscwbVtA+mN 20 | R+c0fu0++WZTkWNduz2PuErTNZGoa3ydOBKedC7Y2RfhYXuFoIL7NsjfZGiG5Vrv 21 | J0Bd9n2cKFo5hrOo4wyaaiMCgYEAqVD5m5fpeuQo8ZCMTCzGNLq0juoFauig7Lu9 22 | RmPVXXiyMCxwGUdc5Vrgg6nylVWjQDbKZmXfhe8Y+no55i2gIDSdDxa4Di5U8+1A 23 | aJVvPYvCWn8OcbmHOBKcWFPuT11/MCULHCvHWDo0x0XdRXslOCqv1q2JQdtzMm5q 24 | I2DTdUkCgYAoBnEsvhxlP6REhcTeG4CQ27b2JNe8hmOrKArX5oDW1/9pOa41Optn 25 | lu2qOBMiwbiDZunpAINdrmZc1GrO1YzjO5rVRRdnc5/l01rb83AncWfhoYC4VMFD 26 | An64sZJanLoxiqbKeKSnw888neICj2ryDGwj4b/ebxRtUoFX6Y6Cqw== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /django_afip/testing/tiny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhyNotHugo/django-afip/07d50b6af2315121a54b090f7566f812d77cf47f/django_afip/testing/tiny.png -------------------------------------------------------------------------------- /django_afip/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | 5 | from django.utils.functional import cached_property 6 | from django_renderpdf.views import PDFView 7 | 8 | from django_afip import models 9 | from django_afip.pdf import PdfBuilder 10 | 11 | 12 | class ReceiptPDFView(PDFView): 13 | #: The PDF Builder class to use for generating PDF files. 14 | #: 15 | #: Set this to a custom subclass if you need custom behaviour for your PDF files. 16 | builder_class = PdfBuilder 17 | 18 | @cached_property 19 | def receipt(self) -> models.Receipt: 20 | """Returns the receipt. 21 | 22 | Returns the same in-memory instance during the whole request.""" 23 | 24 | return models.Receipt.objects.select_related( 25 | "receipt_type", 26 | "point_of_sales", 27 | ).get( 28 | pk=self.kwargs["pk"], 29 | ) 30 | 31 | @cached_property 32 | def builder(self) -> PdfBuilder: 33 | """Returns the pdf builder. 34 | 35 | Returns the same in-memory instance during the whole request.""" 36 | 37 | return self.builder_class() 38 | 39 | @property 40 | def download_name(self) -> str: 41 | """Return the filename to be used when downloading this receipt.""" 42 | return f"{self.receipt.formatted_number}.pdf" 43 | 44 | def get_template_names(self) -> list[str]: 45 | """Return the templates use to render the Receipt PDF. 46 | 47 | See :meth:`~.PdfBuilder.get_template_names` for exact implementation details. 48 | """ 49 | return self.builder.get_template_names(self.receipt) 50 | 51 | @staticmethod 52 | def get_context_for_pk(pk: int, *args, **kwargs) -> dict: 53 | """Returns the context for a receipt. 54 | 55 | Note that this uses ``PdfBuilder`` and not ``self.builder_class`` due to legacy 56 | reasons. 57 | 58 | .. deprecated:: 12.0 59 | 60 | This method is deprecated, use :meth:`~.PdfBuilder.get_context` instead. 61 | """ 62 | warnings.warn( 63 | "ReceiptPDFView.get_context_for_pk is deprecated; " 64 | "use PdfBuilder.get_context instead", 65 | DeprecationWarning, 66 | stacklevel=2, 67 | ) 68 | receipt = models.Receipt.objects.get(pk=pk) 69 | return PdfBuilder().get_context(receipt) 70 | 71 | def get_context_data(self, pk: int, **kwargs) -> dict: 72 | context = super().get_context_data(pk=pk, **kwargs) 73 | context.update(self.builder.get_context(self.receipt)) 74 | return context 75 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | Core models 5 | ----------- 6 | 7 | These are the core models which will normally be used for ``Receipt`` validation. 8 | 9 | .. autoclass:: django_afip.models.PointOfSales 10 | :members: 11 | .. autoclass:: django_afip.models.Receipt 12 | :members: 13 | .. autoclass:: django_afip.models.ReceiptValidation 14 | :members: 15 | .. autoclass:: django_afip.models.Tax 16 | :members: 17 | .. autoclass:: django_afip.models.TaxPayer 18 | :members: 19 | .. autoclass:: django_afip.models.Vat 20 | :members: 21 | .. autoclass:: django_afip.models.Optional 22 | :members: 23 | 24 | PDF-related models 25 | ------------------ 26 | 27 | These models are used only for PDF generation, or can be used for storing 28 | additional non-validated metadata. You DO NOT need any of these classes 29 | unless you intend to generate PDFs for receipts. 30 | 31 | .. autoclass:: django_afip.models.ReceiptEntry 32 | :members: 33 | .. autoclass:: django_afip.models.ReceiptPDF 34 | :members: 35 | 36 | PDF builder 37 | ----------- 38 | 39 | .. autoclass:: django_afip.pdf.PdfBuilder 40 | :members: 41 | 42 | .. _metadata-models: 43 | 44 | Metadata models 45 | --------------- 46 | 47 | These models represent metadata like currency types or document types. 48 | 49 | You should make sure you populate these tables either via the ``afipmetadata`` 50 | command, or the ``load_metadata`` function: 51 | 52 | .. autofunction:: django_afip.models.load_metadata 53 | 54 | .. autoclass:: django_afip.models.ConceptType 55 | :members: 56 | .. autoclass:: django_afip.models.CurrencyType 57 | :members: 58 | .. autoclass:: django_afip.models.DocumentType 59 | :members: 60 | .. autoclass:: django_afip.models.Observation 61 | :members: 62 | .. autoclass:: django_afip.models.ReceiptType 63 | :members: 64 | .. autoclass:: django_afip.models.TaxType 65 | :members: 66 | .. autoclass:: django_afip.models.VatType 67 | :members: 68 | .. autoclass:: django_afip.models.OptionalType 69 | :members: 70 | 71 | Managers 72 | -------- 73 | 74 | Managers should be accessed via models. For example, ``ReceiptManager`` 75 | should be accessed using ``Receipt.objects``. 76 | 77 | .. autoclass:: django_afip.models.ReceiptManager 78 | :members: 79 | .. autoclass:: django_afip.models.ReceiptPDFManager 80 | :members: 81 | 82 | QuerySets 83 | --------- 84 | 85 | QuerySets are generally accessed via their models. For example, 86 | ``Receipt.objects.filter()`` will return a ``ReceiptQuerySet``. 87 | 88 | .. autoclass:: django_afip.models.ReceiptQuerySet 89 | :members: 90 | 91 | Helpers 92 | ------- 93 | 94 | .. autofunction:: django_afip.helpers.get_server_status 95 | 96 | .. autoclass:: django_afip.helpers.ServerStatus 97 | :members: 98 | 99 | Exceptions 100 | ---------- 101 | 102 | .. autoclass:: django_afip.exceptions.CannotValidateTogether 103 | :members: 104 | 105 | WebService clients 106 | ------------------ 107 | 108 | These clients provide direct access to AFIP's WS. These are reserved for 109 | advanced usage. 110 | 111 | .. autofunction:: django_afip.clients.get_client 112 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # noqa: INP001 2 | # Configuration file for the Sphinx documentation builder. 3 | # 4 | # This file only contains a selection of the most common options. For a full 5 | # list see the documentation: 6 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 7 | # -- Path setup -------------------------------------------------------------- 8 | # If extensions (or modules to document with autodoc) are in another directory, 9 | # add these directories to sys.path here. If the directory is relative to the 10 | # documentation root, use os.path.abspath to make it absolute, like shown here. 11 | # 12 | from __future__ import annotations 13 | 14 | import os 15 | import sys 16 | from os.path import abspath 17 | from os.path import dirname 18 | from os.path import join 19 | 20 | import django 21 | 22 | import django_afip 23 | 24 | BASE_DIR = dirname(dirname(abspath(__file__))) 25 | 26 | sys.path.insert(0, abspath(join(dirname(__file__), "_ext"))) 27 | sys.path.insert(0, abspath(BASE_DIR)) 28 | 29 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 30 | 31 | django.setup() 32 | 33 | # -- Project information ----------------------------------------------------- 34 | 35 | project = "django-afip" 36 | copyright = "2015-2023, Hugo Osvaldo Barrera" # noqa: A001 37 | author = "Hugo Osvaldo Barrera" 38 | 39 | # The short X.Y version. 40 | version = django_afip.__version__ 41 | # The full version, including alpha/beta/rc tags 42 | release = django_afip.__version__ 43 | 44 | 45 | # -- General configuration --------------------------------------------------- 46 | 47 | # Add any Sphinx extension module names here, as strings. They can be 48 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 49 | # ones. 50 | extensions = [ 51 | "sphinx.ext.autodoc", 52 | "sphinx.ext.viewcode", 53 | "sphinxcontrib_django", 54 | "sphinx.ext.intersphinx", 55 | ] 56 | django_settings = "testapp.settings" 57 | 58 | intersphinx_mapping = { 59 | "django": ( 60 | "https://docs.djangoproject.com/en/stable/", 61 | "https://docs.djangoproject.com/en/stable/_objects/", 62 | ), 63 | "weasyprint": ( 64 | "https://doc.courtbouillon.org/weasyprint/stable/", 65 | None, 66 | ), 67 | } 68 | 69 | # Add any paths that contain templates here, relative to this directory. 70 | templates_path = ["_templates"] 71 | 72 | 73 | # The master toctree document. 74 | master_doc = "index" 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This pattern also affects html_static_path and html_extra_path. 79 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 80 | 81 | 82 | # -- Options for HTML output ------------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = "sphinx_rtd_theme" 88 | 89 | # Theme options are theme-specific and customize the look and feel of a theme 90 | # further. For a list of options available for each theme, see the 91 | # documentation. 92 | # html_theme_options = {} 93 | 94 | # Add any paths that contain custom static files (such as style sheets) here, 95 | # relative to this directory. They are copied after the builtin static files, 96 | # so a file named "default.css" will overwrite the builtin "default.css". 97 | html_static_path = ["_static"] 98 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contribuciones 2 | ============== 3 | 4 | Si tenés dudas o consultas, lo ideal es que abras un issue en GitHub_. Los 5 | issues públicos son lo ideal porque si tenés una duda, es muy probable que el 6 | siguiente programador que quiera usar la librería tenga las mismas dudas o dudas 7 | muy similares. 8 | 9 | .. _GitHub: https://github.com/WhyNotHugo/django-afip 10 | 11 | Testing 12 | ------- 13 | 14 | We use `tox` for tests. `tox` sets up a dedicated virtual environment and runs 15 | tests inside of it. This keeps environments isolated and somewhat deterministic 16 | and reproducible. 17 | 18 | To list all possible test environments, use `tox -l`. To run tests in an 19 | environment, use `tox -e ENVIRONMENT`. A quick way to run all unit tests is to 20 | use `tox -e py-sqlite`. 21 | 22 | If you find a bug, try and write a test that reproduces it. This will make 23 | finding a solution easier but also avoid regression on that same issue. 24 | 25 | There are also live tests. These are executed automatically on CI and test 26 | using AFIP's testing servers. These tests are somewhat flaky because 27 | authentication cannot be done too often. Skipping tests for authentication 28 | seems like a great way to break authentication-related code without noticing. 29 | 30 | Live tests are run only when using `tox -e live`. 31 | 32 | Bases de datos de testing 33 | ------------------------- 34 | 35 | Los tests corren con tres bases de datos: `mysql`, `postgres` y `sqlite`. 36 | Generalmente correr los tests con `sqlite` basta, pero para evitar problemas de 37 | compatibilidad, CI corre con los tres. 38 | 39 | Para corer los servidores de prueba localmente de forma efímera, podés usar 40 | docker: 41 | 42 | .. code-block:: bash 43 | 44 | # Para postgres: 45 | docker run --env=POSTGRES_PASSWORD=postgres --publish=5432:5432 --rm postgres:13 46 | # Para mysql / mariadb: 47 | docker run --env=MYSQL_ROOT_PASSWORD=mysql --publish=3306:3306 --rm -it mariadb:10 48 | 49 | Tené en cuenta que los servidores pueden tardar un par de segundos en 50 | arrancar. Si estás corriendo tests a mano no es un problema, pero si estás 51 | scripteando, conviene agregar un delay cortito (o usar healthchecks). 52 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | django-afip 2 | =========== 3 | 4 | (:ref:`See here for English `) 5 | 6 | **django-afip** es una aplicación Django para interactuar con los web-services 7 | del AFIP. Actualmente están implementados WSFE y WSAA. 8 | 9 | El código está actualmente en GitHub_. Si tenés alguna pregunta o duda, ese es 10 | el lugar donde consultar. 11 | 12 | .. _GitHub: https://github.com/WhyNotHugo/django-afip 13 | 14 | Funcionalidades 15 | --------------- 16 | 17 | * Validar comprobantes (facturas y otros tipos) con el servicio WSFE del AFIP. 18 | * Generar PDFs de comprobantes validados (listos para enviar a clientes). 19 | 20 | Diseño 21 | ------ 22 | 23 | ``django-afip`` nació de la necesidad de automatizar facturación para un 24 | e-commerce. Los clientes arman su pedido, pagan online, y el sistema genera 25 | facturas automáticamente, las valida con el AFIP, y se las manda por email. 26 | 27 | Actualmente no hay vistas ni formularios para manualmente crear o validar 28 | facturas. El admin funciona, pero es más una herramienta de desarrollo e 29 | inspección que algo pulido para usuario no-técnicos. 30 | 31 | Generalmente los casos de uso son no-interactivos, donde las facturas son 32 | generadas automáticamente en base a modelos pre-existentes (por ejemplo, con 33 | datos de pedidos en el mismo u otro sistema), por lo cual no hay demasiada 34 | funcionalidad relacionada a validar input manual. 35 | 36 | Si te encontrás necesitando validar datos cargados por el usuario para 37 | facturación, preguntate si realmente la debería estar cargando el usuario. 38 | Muchas veces la información ya está en algún otro sistema y es ideal leer eso 39 | en vez de agregar una carga manual. 40 | 41 | Aún así, son bienvenidos parches que agreguen funcionalidad reusable de 42 | formularios, vistas o serializers para DRF. 43 | 44 | Sólo Django? 45 | ------------ 46 | 47 | Si estás considerando usar otro framework web en Python, y el hecho de que 48 | esto esté implementado en Django te desmotiva, te insto a reconsiderar. 49 | 50 | Integrar con servicios del AFIP es algo no-trivial, y tiene muchas 51 | peculiaridades. Is pensás que usar algo como Flask va a ser más sencillo y 52 | rápido, probablemente termines re-implementando la mitad de Django y esta 53 | librería a mano. Podría evitarte ese trabajo usando algo ya-hecho. 54 | 55 | 56 | Recomiendo ver este artículo en el tema: 57 | `Use Django or end up building a Django `_ 58 | 59 | Requisito 60 | --------- 61 | 62 | Actualmente **django-afip** funciona con: 63 | 64 | * Django 3.0, 3.1 y 3.1 65 | * Python 3.9, 3.10, 3.11, 3.12 and 3.13 66 | * Posgres, Sqlite, MySql/MariaDB 67 | 68 | Te recomendamos usar Postgres. 69 | 70 | Versiones más viejas de ``django-afip`` continúan funcionando con veriones 71 | viejas de Django y Python, y lo continuarán haciendo a no ser que AFIP haga 72 | cambios incompatibles. Sin embargo, no recibirán nueva funcionalidades ni 73 | actualizaciones en caso de que AFIP haga cambios a sus webservices. 74 | 75 | Tabla de contenidos 76 | =================== 77 | 78 | .. toctree:: 79 | :maxdepth: 2 80 | 81 | installation 82 | usage 83 | printables 84 | api 85 | seguridad 86 | contributing 87 | changelog 88 | 89 | Índices y tablas 90 | ================ 91 | 92 | * :ref:`genindex` 93 | * :ref:`modindex` 94 | * :ref:`search` 95 | 96 | .. _English: 97 | 98 | English 99 | ======= 100 | 101 | **django-afip** is a django application for interacting with AFIP's 102 | web-services (and models all related data). 103 | 104 | AFIP is Argentina's tax collection agency, which requires invoices and other 105 | receipts be informed to them via a WSDL-based API. 106 | 107 | Initially this project and its documentation was fully available in English, 108 | since one of the applications using it had contributors from abroad. 109 | 110 | This is no longer the case, and given that, naturally, most developers seeks to 111 | use this library are from Argentina, documentation has been translated to make 112 | collaboration simpler. 113 | 114 | Feel free to open issue in English if that's you're native tongue. Paid 115 | consultancy for integrations is also available. 116 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Instalación 2 | =========== 3 | 4 | Podés instalar este paquete usando pip: 5 | 6 | .. code-block:: sh 7 | 8 | pip install django-afip 9 | 10 | La generación de PDFs usa ``weasyprint``, que tiene algunas dependencias 11 | adicionales. Consultá :doc:`su documentación ` para 12 | instrucciones detalladas al día. 13 | 14 | Recomendamos usar algo como Poetry_ para manejar dependencias y asegurarte de 15 | que las versiónes de tus dependencies no cambien de repente sin tu 16 | intervención. 17 | 18 | .. _Poetry: https://python-poetry.org/ 19 | 20 | Settings 21 | -------- 22 | 23 | Vas a necesitar agregar la aplicación al ``settings.py`` de tu proyecto: 24 | 25 | .. code-block:: python 26 | 27 | INSTALLED_APPS = ( 28 | ... 29 | 'django_afip', 30 | ... 31 | ) 32 | 33 | Asegurate de correr todas las migraciones después de agregar la app: 34 | 35 | .. code-block:: sh 36 | 37 | python manage.py migrate afip 38 | 39 | Vas a necesitar correr las migraciones en cada ambiente / base de datos que 40 | uses. 41 | 42 | Cada versión nueva de django-afip puede incluir nuevas migraciones, y 43 | recomendamos seguir la práctica estándar de correr las migraciones en cada 44 | deploy. 45 | 46 | Metadatos 47 | --------- 48 | 49 | Vas a necesitar algunos metadatos adicionales para poder hacer integraciones. 50 | Esto son "Tipos de comprobante" (:class:`~.ReceiptType`), "Tipos de documento" 51 | (:class:`~.DocumentType`), y :ref:`unos cuantos más `. 52 | 53 | Estos metadatos están disponibles via la API de AFIP, pero esta librería 54 | incluye esos mismos datos como fixtures que podés importar fácilmente:: 55 | 56 | python manage.py afipmetadata 57 | 58 | Este comando es idempotente, y correrlo más de una vez no crea datos 59 | duplicados. 60 | 61 | Los metadatos también pueden ser importados programáticamente, usando 62 | :func:`~.models.load_metadata`. Esto es útil para tests unitarios que dependan 63 | de su existencia. 64 | 65 | .. hint:: 66 | 67 | Es necesario importar estos datos en cada instancia / base de datos, al igual 68 | que migraciones. La recomendación es correr el comando de arriba en el mismo 69 | script que dispare las migraciones. Esto asegura que todos tus ambientes 70 | siempre tengan metadatos al día. 71 | 72 | Almacenamiento 73 | -------------- 74 | 75 | También es posible (y opcional) definir varios :doc:`Storage 76 | ` para los archivos de la app. Si no están definidos, 77 | se usará el Storage predeterminado. 78 | 79 | El valor de estos ajustes debería ser un ``str`` con el path a una instancia 80 | del storage a usar. (eg: ``'myapp.storages.my_private_storage'``). Tanto S3 81 | como el storage predeterminado han sido testeados ampliamente, aunque cualquier 82 | storage compatible con django debería funcionar sin dramas. 83 | 84 | .. code-block:: python 85 | 86 | AFIP_KEY_STORAGE # Clave para autenticación con el AFIP. (TaxPayer.key) 87 | AFIP_CERT_STORAGE # Certificados para autenticación con el AFIP (TaxPayer.certificate) 88 | AFIP_PDF_STORAGE # PDFs generados para comprobantes (ReceiptPDF.pdf_file) 89 | AFIP_LOGO_STORAGE # Logos usados para comprobantes (TaxPayer.logo) 90 | 91 | Si estás exponiendo tu Storage predeterminado a la web (que suele ser el caso 92 | en muchas aplicaciones), es recomendable, como mínimo, redefinir 93 | ``AFIP_KEY_STORAGE`` para evitar exponer tu claves a la web. 94 | 95 | Versionado 96 | ---------- 97 | 98 | Recomendamos pinnear versiones de dependencias. Las versiones mayores (e.g.: 99 | de 8.X a 9.X) pueden requerir actualizar código. Esto no implica re-escribir 100 | todo, pero suelen haber consideraciones que tenés que tener en cuenta. 101 | 102 | El :doc:`changelog ` siempre incluye detalles de cambios en la API 103 | y ajustes que sean necesario. 104 | 105 | Si estás usando ``requirements.txt``, usá algo como:: 106 | 107 | django-afip>=8.0,< 9.0 108 | 109 | Seguimos estrictamente `Semantic Versioning`_. 110 | 111 | .. _Semantic Versioning: http://semver.org/ 112 | 113 | Actualizaciones 114 | --------------- 115 | 116 | Compatibilidad para atrás puede romper en versiones mayores, aunque siempre 117 | incluimos migraciones para actualizar instalaciones existentes. Usamos estas 118 | mismas migraciones para actualizar instancias productivas año tras año. 119 | 120 | .. warning:: 121 | 122 | Si estás usando una versión previa a 4.0.0, deberías actualizar a 4.0.0, 123 | ejecutar las migraciones, y luego continuar. La migraciones fueros 124 | squasheadas en esa versión y no está garantizado que actualizar salteándola 125 | funcione. 126 | 127 | .. _database-support: 128 | 129 | Database support 130 | ---------------- 131 | 132 | Postgres is recommended. MySQL is supported, but CI runs are limited. If you 133 | use a combination missing from the CI run matrix, feel free to reach out. 134 | sqlite should work, and is only supported with the latest Python and latest 135 | Django. sqlite should only by used for prototypes, demos, or example projects, 136 | as it has not been tested for production-grade reliability. 137 | 138 | .. _transactions: 139 | 140 | Transactions 141 | ............ 142 | 143 | Generally, avoid calling network-related methods within a transaction. The 144 | implementation has assumptions that they **are not** called during a request 145 | cycle. Assumptions are made about this, and `django-afip` handles transactions 146 | internally to keep data consistent at all times. 147 | -------------------------------------------------------------------------------- /docs/printables.rst: -------------------------------------------------------------------------------- 1 | Impresiones 2 | ----------- 3 | 4 | Originalmente **django-afip** no soportaba generación de PDFs o comprobantes a 5 | imprimir, dado que esto lo hacían sistemas externos. 6 | 7 | Eventualmente esto cambió, y la generación de PDFs se integró a esta librería, 8 | pero la integración no está al 100%, por lo cual la mayoría del código para 9 | generar los PDF es opcional. 10 | 11 | Actualmente soportamos generar PDFs para comprobantes y esto está respaldado 12 | principalmente por tres clases. Sólo necesitás usar estas clases si estás 13 | generando los PDF con esta librería, y podés ignorarlas si estás generándolos 14 | de otra forma: 15 | 16 | * :class:`~.ReceiptPDF`: Contiene metadatos individuales de cada comprobante. 17 | Los datos de ``PointOfSales`` también se copian acá, dado que en caso de que 18 | cambie, por ejemplo, el domicilio del contribuyente, no debería cambiar el 19 | domicilio en comprobantes pasados. 20 | * :class:`~.ReceiptEntry`: Representa una línea del detalle de un comprobante. 21 | 22 | Primero deberías generar los ``ReceiptEntry`` para tu comprobante y después 23 | generar el ``ReceiptPDF``. Esto último lo podés hacer usando el helper 24 | :meth:`~.ReceiptPDFManager.create_for_receipt`. 25 | 26 | Los archivos PDF en sí son generados la primera vez que guardes una instancia 27 | de ``ReceiptPDF`` (mediante un hook ``pre_save``). Podés regenerar el PDF 28 | usando :meth:`.ReceiptPDF.save_pdf`. 29 | 30 | Códigos QR 31 | ~~~~~~~~~~ 32 | 33 | Los PDF incluyen el código QR que es requerido desde Marzo 2021. 34 | 35 | Actualmente cualquier QR redirige a la documentación del AFIP (includo lo de 36 | sus ejemplos y otra implementaciones). Esto parece ser porque AFIP nunca 37 | terminó de implementar su parte, y está fuera de nuestro control. 38 | 39 | Exponiendo comprobantes 40 | ~~~~~~~~~~~~~~~~~~~~~~~ 41 | 42 | Vistas 43 | ...... 44 | 45 | Los comprobantes pueden exponerse mediante una vista. Requirre el `pk` del 46 | comprobante, así que la registración de la URL debería ser algo como: 47 | 48 | .. code-block:: python 49 | 50 | path( 51 | "receipts/pdf/", 52 | views.ReceiptPDFView.as_view(), 53 | name="receipt_view", 54 | ), 55 | 56 | Esto usa **django_renderpdf**, y es una subclase de ``PDFView``. 57 | 58 | Recomendamos generalmente usar una subclase de ``ReceiptPDFView``, que tenga 59 | alguna forma de autenticación y autorizacion. 60 | 61 | .. autoclass:: django_afip.views.ReceiptPDFView 62 | :members: 63 | 64 | Templates 65 | ......... 66 | 67 | Los templates para las vistas son buscados en 68 | ``templates/receipts/code_X.html``, dónde X es el código del tipo de 69 | comprobante (:class:`~.ReceiptType`). Si querés overridear el predetermindo, 70 | simplemente incluí en tu projecto un template con el mismo nombre/path, y 71 | asegurate de que te projecte esté listado *antes* que ``django_afip`` en 72 | ``INSTALLED_APPS``. 73 | 74 | También podés exponer los archivos generados 75 | 76 | Note that you may also expose receipts as plain Django media files. The URL 77 | will be relative or absolute depending on your media files configuration. 78 | 79 | .. code-block:: pycon 80 | 81 | >>> printable = ReceiptPDF.objects.last() 82 | >>> printable.pdf_file 83 | 84 | >>> printable.pdf_file.url 85 | '/media/receipts/790bc4f648e844bda7149ac517fdcf65.pdf' 86 | 87 | Los templates provistos siguen las indicaciones de la `RG1415`_, que regula que 88 | campos deben contener y dónde debe estar ubicado cada dato. 89 | 90 | .. _RG1415: http://biblioteca.afip.gob.ar/dcp/REAG01001415_2003_01_07 91 | -------------------------------------------------------------------------------- /docs/security.rst: -------------------------------------------------------------------------------- 1 | Seguridad 2 | ========= 3 | 4 | En Juio de 2020, AFIP reconfiguró sus servidores e introdujo un problema de 5 | seguridad: las claves Diffie-Hellman que usa son demasiado pequeñas y 6 | consideradas débiles por la mayoría de las librerías de SSL/TLS. 7 | 8 | Esto es fácil de verificar en ``sh`` con:: 9 | 10 | > curl -Lo /dev/null 'https://servicios1.afip.gov.ar/wsfev1/service.asmx?WSDL' 11 | curl: (35) OpenSSL/3.3.0: error:0A00018A:SSL routines::dh key too small 12 | 13 | O en ``python`` con:: 14 | 15 | >>> requests.get("https://servicios1.afip.gov.ar/wsfev1/service.asmx?WSDL") 16 | SSLError: HTTPSConnectionPool(host='servicios1.afip.gov.ar', port=443): Max retries exceeded with url: /wsfev1/service.asmx?WSDL (Caused by SSLError(SSLError(1, '[SSL: DH_KEY_TOO_SMALL] dh key too small (_ssl.c:1000)'))) 17 | 18 | El problema sólo aplica a los servidores de producción, y no a los servidores de 19 | testing. 20 | 21 | Reporté el problema al AFIP, pero las respuestas que recibí me dieron la 22 | impresión de que no tenían idea de que estaba hablando. No logré que mi mensaje 23 | se re-envíe a alguien capaz de resolver el problema. 24 | 25 | Desde entonces, ``django-afip`` incluye código para aceptar claves 26 | Diffie-Hellman inseguras. Esta es la única solución si uno quiere comunicarse 27 | con servidores de producción. Cuatro años después la situación sigue igual. 28 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Uso 2 | ===== 3 | 4 | Glosario 5 | -------- 6 | 7 | Tené en cuenta estos términos mientras leas la documentación. Nótese que no 8 | estamos reinventando nada acá, estos son los términos que usa el AFIP, aunque 9 | pueden ser no-obvios para desarrolladores. 10 | 11 | - **Contribuyente** / :class:`.TaxPayer`: La persona o entidad de parte de la 12 | cual vas a estar generando comprobantes. 13 | - **Comprobante** / :class:`.Receipt`: Estos pueden ser facturas, notas de 14 | créditos, etc. 15 | - **Punto de ventas** / :class:`.PointOfSales`: Cada punto de ventas sigue una 16 | secuencia de numeración propia. El punto de ventas 9 emite comprobantes con 17 | números ``0009-00000001``, ``0009-00000002``, etc. 18 | 19 | Cómo empezar 20 | --------------- 21 | 22 | Antes que nada, deberías crear una instancia de la clase :class:`~.TaxPayer`. 23 | Vas a necesitar generar claves y registrarlas con el AFIp antes de poder 24 | continuar (más detalles más abajo). 25 | 26 | ``django-afip`` incluye un vistas del admin para cada modelo incluido, y es la 27 | forma recomendad de crear un ``TaxPayer``, al menos durante desarrollo y 28 | deploys iniciales. 29 | 30 | Crear una clave privada 31 | ~~~~~~~~~~~~~~~~~~~~~~~ 32 | 33 | Hay tres formas de crear una clave privada, las cuales dan resultados equivalentes: 34 | 35 | 1. Seguí las: `instrucciones oficiales `_. 36 | 2. Usá el método :meth:`~.TaxPayer.generate_key`. Esto genera la clave y la 37 | guarda en el sistema. 38 | 3. Usando el admin, usá la acción "generate key". Esto es simplemente un 39 | wrapper a la función (2) que te ofrece guardar el archivo que necesitás 40 | mandar al AFIP. 41 | 42 | La recomendación es usar la última de estas opciones, dado que es la más fácil, 43 | o, en caso de no estar usando el admin, la segunda opción. 44 | 45 | Registración de clave con el AFIP 46 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 47 | 48 | Vas a necesitar registrar tu clave con el AFIP: 49 | 50 | 1. `Registrá la clave para autenticación `_. 51 | 2. `Registrá la clave para facturación `_. 52 | 3. `Creá un punto de ventas `_. 53 | 54 | Vas a obtener un certificado durante este proceso. Deberías asignar este 55 | certificado al atributo ``certificate`` de tu ``TaxPayer`` (de nuevo, podés 56 | hacer esto mediante el admin). 57 | 58 | Obtención de puntos de ventas 59 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 60 | 61 | Una vez que hayas creado un :class:`~.TaxPayer`, vas a necesitar sus puntos de 62 | venta. Esto podés hacerlo mediante el admin (útil durando expirimentación / 63 | desarrollo) usando "fetch points of sales", o usando 64 | :meth:`~.TaxPayer.fetch_points_of_sales`. 65 | 66 | Resúmen 67 | ~~~~~~~ 68 | 69 | Este ejemplo muestra como setear una clave y certificado existentes, y demás 70 | pasos de forma programatica: 71 | 72 | .. code-block:: python 73 | 74 | from django.core.files import File 75 | from django_afip import models 76 | 77 | # Create a TaxPayer object: 78 | taxpayer = models.TaxPayer( 79 | pk=1, 80 | name='test taxpayer', 81 | cuit=20329642330, 82 | is_sandboxed=True, 83 | active_since='2021-01-01', 84 | ) 85 | 86 | # Add the key and certificate files to the TaxPayer: 87 | with open('/path/to/your.key') as key: 88 | taxpayer.key.save('test.key', File(key)) 89 | with open('/path/to/your.crt') as crt: 90 | taxpayer.certificate.save('test.crt', File(crt)) 91 | 92 | taxpayer.save() 93 | 94 | # Load all metadata: 95 | models.load_metadata() 96 | 97 | # Get the TaxPayer's Point of Sales: 98 | taxpayer.fetch_points_of_sales() 99 | 100 | 101 | .. TODO: estaría bueno acá un paso de revisar que todo anda y conecta okay 102 | .. TODO: además de troubleshooting de los problemas típicos. 103 | 104 | Validación de comprobantes 105 | -------------------------- 106 | 107 | Tras completar los pasos anteriores ya deberías estar listo para emitir 108 | comprobantes. 109 | 110 | El primer paso es crear un comprobante, creando una instancia de 111 | :class:`~.Receipt`. 112 | 113 | Para validar el comprobante, usá el método :meth:`.Receipt.validate`. 114 | Recomendamos no especificar un ticket explíticatmente y dejar que la librería 115 | se encargue de la autenticación 116 | 117 | Acerca del admin 118 | ---------------- 119 | 120 | La mayoría de los modelos incluyen vistas del admin. Si necesitás cambios, te 121 | recomendamos usar subclases y evitar re-escribirlas. 122 | 123 | Las vistas del admin incluídas actualmente están más orientadas a 124 | desarrolladores (para desarrollo, testeo manual, y inspeccionar producción), o 125 | para usuarios técnicos de bajo volúmen. **No** están diseñadas para el usuario 126 | final o usuarios no-técnicos. 127 | 128 | Type annotations 129 | ---------------- 130 | 131 | Most of this library's public interface includes type annotations. Applications 132 | using this library may use ``mypy`` and ``django-stubs`` to perform 133 | type-checking and find potential issues earlier in the development cycle. 134 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-afip" 3 | authors = [ 4 | {name = "Hugo Osvaldo Barrera", email = "hugo@whynothugo.nl"}, 5 | ] 6 | description = "AFIP integration for django" 7 | readme = "README.rst" 8 | requires-python = ">=3.9" 9 | keywords = ["argentina", "afip", "wsdl"] 10 | license = "ISC" 11 | license-files = ["LICENCE"] 12 | classifiers = [ 13 | "Development Status :: 6 - Mature", 14 | "Environment :: Web Environment", 15 | "Framework :: Django", 16 | "Framework :: Django :: 4.2", 17 | "Framework :: Django :: 5.0", 18 | "Framework :: Django :: 5.1", 19 | "Intended Audience :: Developers", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | "Topic :: Internet :: WWW/HTTP", 28 | "Topic :: Software Development :: Libraries :: Python Modules", 29 | "Typing :: Typed", 30 | ] 31 | dependencies = [ 32 | "cryptography>=3.2,<40", 33 | "django>=4.2,<5.2", 34 | "django_renderpdf>=3.0.0,<5.0.0", 35 | "lxml>=3.4.4", 36 | "pyopenssl>=16.2.0", 37 | 'backports.zoneinfo;python_version<"3.9"', 38 | "setuptools-git>=1.1", 39 | "wheel>=0.24.0", 40 | "zeep>=1.1.0,<5.0.0", 41 | "qrcode[pil]>=6.1,<8.0", 42 | "pyyaml>=5.3.1,<7.0.0", 43 | # zeep depends on requests, but these specific version are incompatible. 44 | # See: https://github.com/WhyNotHugo/django-afip/issues/211 45 | "requests!=2.32.0,!=2.32.1,!=2.32.2", 46 | ] 47 | dynamic = ["version"] 48 | 49 | [project.optional-dependencies] 50 | docs = [ 51 | "Sphinx>=3.4.0", # See: https://github.com/edoburu/sphinxcontrib-django/issues/49 52 | "sphinx_rtd_theme", 53 | "sphinxcontrib_django", 54 | "dj_database_url", 55 | ] 56 | postgres = [ 57 | "psycopg2", 58 | ] 59 | mysql = [ 60 | "mysqlclient", 61 | ] 62 | factories = [ 63 | "factory-boy", 64 | ] 65 | dev = [ 66 | "coverage", 67 | "dj_database_url", 68 | "django-stubs[compatible-mypy]", 69 | "factory-boy", 70 | "freezegun", 71 | "pytest-cov", 72 | "pytest-django", 73 | "types-backports", 74 | "types-freezegun", 75 | "types-pyOpenSSL", 76 | "types-pytz", 77 | "types-requests" 78 | ] 79 | 80 | [project.urls] 81 | homepage = "https://github.com/WhyNotHugo/django-afip" 82 | documentation = "https://django-afip.readthedocs.io/" 83 | issues = "https://github.com/WhyNotHugo/django-afip/issues" 84 | changelog = "https://django-afip.readthedocs.io/en/latest/changelog.html" 85 | donate = "https://liberapay.com/WhyNotHugo/" 86 | funding = "https://github.com/sponsors/WhyNotHugo" 87 | 88 | [tool.setuptools.packages.find] 89 | include = ["django_afip*"] 90 | 91 | [build-system] 92 | requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2"] 93 | 94 | [tool.setuptools_scm] 95 | write_to = "django_afip/version.py" 96 | version_scheme = "no-guess-dev" 97 | 98 | [tool.pytest.ini_options] 99 | addopts = """ 100 | --reuse-db 101 | --cov=django_afip 102 | --cov-report=term-missing:skip-covered 103 | --no-cov-on-fail 104 | --color=yes 105 | """ 106 | markers = [ 107 | "live: Tests done with the live test environment." 108 | ] 109 | DJANGO_SETTINGS_MODULE = "testapp.settings" 110 | 111 | [tool.mypy] 112 | ignore_missing_imports = true 113 | plugins = ["mypy_django_plugin.main"] 114 | 115 | [tool.django-stubs] 116 | django_settings_module = "testapp.settings" 117 | 118 | [tool.coverage.run] 119 | source = ["django_afip"] 120 | 121 | [tool.ruff.lint] 122 | select = [ 123 | "F", 124 | "E", 125 | "W", 126 | "I", 127 | # "N", 128 | "UP", 129 | "YTT", 130 | "ANN", 131 | "B", 132 | "A", 133 | "C4", 134 | 135 | "ISC", 136 | "ICN", 137 | "G", 138 | "INP", 139 | "PIE", 140 | "PYI", 141 | 142 | "PT", 143 | "Q", 144 | "RSE", 145 | "RET", 146 | "SIM", 147 | "TID", 148 | "TCH", 149 | "INT", 150 | "PGH", 151 | "PLE", 152 | "RUF", 153 | ] 154 | ignore = [ 155 | "ANN002", # Annotations for *args 156 | "ANN003", # Annotations for **kwargs 157 | ] 158 | 159 | [tool.ruff.lint.isort] 160 | force-single-line = true 161 | required-imports = ["from __future__ import annotations"] 162 | 163 | [tool.ruff.lint.per-file-ignores] 164 | # Fails with auto-generated migrations. Unsolvable contradiction between ruff and mypy. 165 | # This likely needs to be addressed in Django itself (either use an immutable 166 | # type or annotate these fields as ClassVar) 167 | "django_afip/migrations/0*.py"= ["RUF012"] 168 | 169 | [tool.coverage.report] 170 | exclude_lines = [ 171 | "if TYPE_CHECKING:", 172 | ] 173 | -------------------------------------------------------------------------------- /scripts/dump_metadata.py: -------------------------------------------------------------------------------- 1 | # noqa: INP001 2 | """Script used to generate AFIP metadata fixtures. 3 | 4 | This script is used to generate the fixtures with AFIP metadata. It is only used to 5 | BUILD django_afip, and should not be used directly by third-party apps. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import django 11 | from django.core import management 12 | 13 | if __name__ == "__main__": 14 | # Set up django... 15 | django.setup() 16 | management.call_command("migrate") 17 | 18 | from django_afip.factories import TaxPayerFactory 19 | from django_afip.models import GenericAfipType 20 | 21 | # Initialise (uses test credentials). 22 | TaxPayerFactory() 23 | 24 | # Fetch and dump data: 25 | for model in GenericAfipType.SUBCLASSES: 26 | # TYPING: mypy can't see custom manager type. 27 | # See: https://github.com/typeddjango/django-stubs/issues/1067 28 | model.objects.populate() # type: ignore[attr-defined] 29 | 30 | label = model._meta.label.split(".")[1].lower() 31 | 32 | management.call_command( 33 | "dumpdata", 34 | f"afip.{label}", 35 | format="yaml", 36 | use_natural_primary_keys=True, 37 | output=f"django_afip/fixtures/{label}.yaml", 38 | ) 39 | -------------------------------------------------------------------------------- /testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhyNotHugo/django-afip/07d50b6af2315121a54b090f7566f812d77cf47f/testapp/__init__.py -------------------------------------------------------------------------------- /testapp/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | import os 5 | import sys 6 | 7 | if __name__ == "__main__": 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 9 | 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /testapp/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | 6 | import dj_database_url 7 | 8 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 9 | BASE_DIR = Path(__file__).resolve().parent.parent 10 | 11 | 12 | # Quick-start development settings - unsuitable for production 13 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 14 | 15 | # SECURITY WARNING: keep the secret key used in production secret! 16 | SECRET_KEY = "dm=3#ff_%6wpr8ynq!r_+kdl$c!$gez$@x!o==k1k96im4ckk+" 17 | 18 | # SECURITY WARNING: don't run with debug turned on in production! 19 | DEBUG = True 20 | 21 | ALLOWED_HOSTS: list = [] 22 | 23 | 24 | # Application definition 25 | 26 | INSTALLED_APPS = ( 27 | "django.contrib.admin", 28 | "django.contrib.auth", 29 | "django.contrib.contenttypes", 30 | "django.contrib.sessions", 31 | "django.contrib.messages", 32 | "django.contrib.staticfiles", 33 | "testapp.testmain", 34 | "django_afip", 35 | ) 36 | 37 | MIDDLEWARE = ( 38 | "django.contrib.sessions.middleware.SessionMiddleware", 39 | "django.middleware.common.CommonMiddleware", 40 | "django.middleware.csrf.CsrfViewMiddleware", 41 | "django.contrib.auth.middleware.AuthenticationMiddleware", 42 | "django.contrib.messages.middleware.MessageMiddleware", 43 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 44 | "django.middleware.security.SecurityMiddleware", 45 | ) 46 | 47 | ROOT_URLCONF = "testapp.urls" 48 | 49 | TEMPLATES = [ 50 | { 51 | "BACKEND": "django.template.backends.django.DjangoTemplates", 52 | "DIRS": [], 53 | "APP_DIRS": True, 54 | "OPTIONS": { 55 | "context_processors": [ 56 | "django.template.context_processors.debug", 57 | "django.template.context_processors.request", 58 | "django.contrib.auth.context_processors.auth", 59 | "django.contrib.messages.context_processors.messages", 60 | ], 61 | }, 62 | }, 63 | ] 64 | 65 | WSGI_APPLICATION = "testapp.wsgi.application" 66 | 67 | # Database 68 | DATABASES = {"default": dj_database_url.config()} 69 | 70 | # Internationalization 71 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 72 | 73 | LANGUAGE_CODE = "en-us" 74 | 75 | TIME_ZONE = "UTC" 76 | 77 | USE_I18N = True 78 | 79 | USE_TZ = True 80 | 81 | 82 | # Static files (CSS, JavaScript, Images) 83 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 84 | 85 | STATIC_URL = "/static/" 86 | STATIC_ROOT = "/tmp/static/" 87 | 88 | MEDIA_URL = "/media/" 89 | MEDIA_ROOT = os.path.join(BASE_DIR, "media/") 90 | -------------------------------------------------------------------------------- /testapp/test_helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from django_afip.helpers import ServerStatus 6 | from django_afip.helpers import get_server_status 7 | 8 | 9 | @pytest.mark.live 10 | def test_get_server_status_production() -> None: 11 | status = get_server_status(True) 12 | 13 | assert isinstance(status, ServerStatus) 14 | 15 | 16 | @pytest.mark.live 17 | def test_get_server_status_testing() -> None: 18 | status = get_server_status(False) 19 | 20 | assert isinstance(status, ServerStatus) 21 | 22 | 23 | @pytest.mark.live 24 | def test_server_status_is_true() -> None: 25 | server_status = ServerStatus(app=True, db=True, auth=True) 26 | 27 | assert server_status 28 | 29 | 30 | @pytest.mark.live 31 | def test_server_status_is_false() -> None: 32 | server_status = ServerStatus(app=False, db=False, auth=False) 33 | 34 | assert not server_status 35 | -------------------------------------------------------------------------------- /testapp/testmain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhyNotHugo/django-afip/07d50b6af2315121a54b090f7566f812d77cf47f/testapp/testmain/__init__.py -------------------------------------------------------------------------------- /testapp/testmain/templates/receipts/20329642330/pos_9999/code_6.html: -------------------------------------------------------------------------------- 1 | This is a dummy template to test template discovery. 2 | -------------------------------------------------------------------------------- /testapp/testmain/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django_afip.views import ReceiptPDFView 4 | 5 | 6 | class ReceiptPDFDownloadView(ReceiptPDFView): 7 | prompt_download = True 8 | -------------------------------------------------------------------------------- /testapp/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.conf import settings 4 | from django.contrib import admin 5 | from django.urls import path 6 | from django.views.static import serve 7 | 8 | from django_afip import views 9 | from testapp.testmain import views as test_views 10 | 11 | urlpatterns = [ 12 | path("admin/", admin.site.urls), 13 | path( 14 | "invoices/pdf/", 15 | views.ReceiptPDFView.as_view(), 16 | name="receipt_displaypdf_view", 17 | ), 18 | path( 19 | "invoices/pdf/", 20 | test_views.ReceiptPDFDownloadView.as_view(), 21 | name="receipt_pdf_view", 22 | ), 23 | path( 24 | "media/", 25 | serve, 26 | {"document_root": settings.MEDIA_ROOT}, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /testapp/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for idf project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import os 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 17 | 18 | application = get_wsgi_application() 19 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhyNotHugo/django-afip/07d50b6af2315121a54b090f7566f812d77cf47f/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | from django.conf import settings 8 | from django.core import serializers 9 | 10 | from django_afip import models 11 | from django_afip.exceptions import AuthenticationError 12 | from django_afip.factories import TaxPayerFactory 13 | from django_afip.factories import get_test_file 14 | from django_afip.models import AuthTicket 15 | 16 | if TYPE_CHECKING: 17 | from collections.abc import Generator 18 | 19 | CACHED_TICKET_PATH = settings.BASE_DIR / "test_ticket.yaml" 20 | _live_mode = False 21 | 22 | 23 | @pytest.fixture(autouse=True) 24 | def disable_durability_check() -> Generator[None, None, None]: 25 | with patch( 26 | "django_afip.models.ReceiptQuerySet._ensure_durability", 27 | False, 28 | spec=True, 29 | ): 30 | yield 31 | 32 | 33 | def pytest_runtest_setup(item: pytest.Function) -> None: 34 | """Set live mode if the marker has been passed to pytest. 35 | 36 | This avoid accidentally using any of the live-mode fixtures in non-live mode.""" 37 | if list(item.iter_markers(name="live")): 38 | global _live_mode 39 | _live_mode = True 40 | 41 | 42 | @pytest.fixture 43 | def expired_crt() -> bytes: 44 | with open(get_test_file("test_expired.crt"), "rb") as crt: 45 | return crt.read() 46 | 47 | 48 | @pytest.fixture 49 | def expired_key() -> bytes: 50 | with open(get_test_file("test_expired.key"), "rb") as key: 51 | return key.read() 52 | 53 | 54 | @pytest.fixture 55 | def live_taxpayer(db: None) -> models.TaxPayer: 56 | """Return a taxpayer usable with AFIP's test servers.""" 57 | return TaxPayerFactory(pk=1) 58 | 59 | 60 | @pytest.fixture 61 | def live_ticket(db: None, live_taxpayer: models.TaxPayer) -> models.AuthTicket: 62 | """Return an authentication ticket usable with AFIP's test servers. 63 | 64 | AFIP doesn't allow requesting tickets too often, so we after a few runs 65 | of the test suite, we can't generate tickets any more and have to wait. 66 | 67 | This helper generates a ticket, and saves it to disk into the app's 68 | BASE_DIR, so that developers can run tests over and over without having to 69 | worry about the limitation. 70 | 71 | Expired tickets are not deleted or handled properly; it's up to you to 72 | delete stale cached tickets. 73 | 74 | When running in CI pipelines, this file will never be preset so won't be a 75 | problem. 76 | """ 77 | assert _live_mode 78 | 79 | # Try reading a cached ticket from disk: 80 | try: 81 | with open(CACHED_TICKET_PATH) as f: 82 | [obj] = serializers.deserialize("yaml", f.read()) 83 | obj.save() 84 | except FileNotFoundError: 85 | # If something failed, we should still have no tickets in the DB: 86 | assert models.AuthTicket.objects.count() == 0 87 | 88 | try: 89 | # Get a new ticket. If the one we just loaded is still valid, that one 90 | # will be returned, otherwise, a new one will be created. 91 | ticket = AuthTicket.objects.get_any_active("wsfe") 92 | except AuthenticationError as e: 93 | pytest.exit(f"Bailing due to failure authenticating with AFIP:\n{e}") 94 | 95 | # No matter how we go it, we must have at least one ticket in the DB: 96 | assert models.AuthTicket.objects.count() >= 1 97 | 98 | data = serializers.serialize("yaml", [ticket], use_natural_primary_keys=True) 99 | with open(CACHED_TICKET_PATH, "w") as f: 100 | f.write(data) 101 | 102 | return ticket 103 | 104 | 105 | @pytest.fixture 106 | def populated_db( 107 | live_ticket: models.AuthTicket, 108 | live_taxpayer: models.TaxPayer, 109 | ) -> None: 110 | """Populate the database with fixtures and a POS""" 111 | 112 | models.load_metadata() 113 | live_taxpayer.fetch_points_of_sales() 114 | -------------------------------------------------------------------------------- /tests/signed_data.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhyNotHugo/django-afip/07d50b6af2315121a54b090f7566f812d77cf47f/tests/signed_data.bin -------------------------------------------------------------------------------- /tests/test_clients.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | import requests 7 | from requests.exceptions import SSLError 8 | 9 | from django_afip.clients import get_client 10 | 11 | 12 | @pytest.mark.live 13 | def test_services_are_cached() -> None: 14 | service1 = get_client("wsfe", False) 15 | with patch.dict("django_afip.clients.WSDLS", values={}, clear=True): 16 | service2 = get_client("wsfe", False) 17 | 18 | assert service1 is service2 19 | 20 | 21 | def test_inexisting_service() -> None: 22 | with pytest.raises(ValueError, match="Unknown service name, nonexistant"): 23 | get_client("nonexistant", False) 24 | 25 | 26 | @pytest.mark.live 27 | def test_insecure_dh_hack_required() -> None: 28 | with pytest.raises(SSLError, match="SSL: DH_KEY_TOO_SMALL. dh key too small"): 29 | requests.get("https://servicios1.afip.gov.ar/wsfev1/service.asmx?WSDL") 30 | -------------------------------------------------------------------------------- /tests/test_crypto.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from django_afip import crypto 8 | 9 | 10 | @pytest.fixture 11 | def signed_data() -> bytes: 12 | path = Path(__file__).parent / "signed_data.bin" 13 | with open(path, "rb") as data: 14 | return data.read() 15 | 16 | 17 | def test_pkcs7_signing( 18 | expired_key: bytes, 19 | expired_crt: bytes, 20 | signed_data: bytes, 21 | ) -> None: 22 | # Use an expired cert here since this won't change on a yearly basis. 23 | data = b"Some data." 24 | 25 | actual_data = crypto.create_embeded_pkcs7_signature(data, expired_crt, expired_key) 26 | 27 | # Data after this index DOES vary depending on current time and other settings: 28 | assert actual_data[64:1100] == signed_data[64:1100] 29 | assert 1717 <= len(actual_data) <= 1790 30 | -------------------------------------------------------------------------------- /tests/test_gentestkey.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from django_afip import factories 8 | 9 | 10 | @pytest.mark.skipif(os.environ.get("GENTESTCSR") is None, reason="not a test") 11 | @pytest.mark.django_db 12 | def test_generate_test_csr() -> None: 13 | """Generate a new test CSR (this is not really a test) 14 | 15 | Run this with: 16 | 17 | GENTESTCSR=yes tox -e py-sqlite -- -k test_generate_test_csr 18 | """ 19 | 20 | # This one is used for most tests. 21 | taxpayer = factories.TaxPayerFactory(is_sandboxed=True) 22 | 23 | csr = taxpayer.generate_csr("wsfe") 24 | with open("test.csr", "wb") as f: 25 | f.write(csr.read()) 26 | 27 | # This one is used for the `test_authentication_with_bad` test. 28 | taxpayer = factories.AlternateTaxpayerFactory(is_sandboxed=True) 29 | 30 | csr = taxpayer.generate_csr("wsfe") 31 | with open("test2.csr", "wb") as f: 32 | f.write(csr.read()) 33 | -------------------------------------------------------------------------------- /tests/test_management.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.core import management 5 | 6 | from django_afip.models import ClientVatCondition 7 | from django_afip.models import GenericAfipType 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_afip_metadata_command() -> None: 12 | assert len(GenericAfipType.SUBCLASSES) == 7 13 | 14 | for model in GenericAfipType.SUBCLASSES: 15 | # TYPING: mypy doesn't know about `.objects`. 16 | assert model.objects.count() == 0 # type: ignore[attr-defined] 17 | 18 | management.call_command("afipmetadata") 19 | 20 | for model in GenericAfipType.SUBCLASSES: 21 | # TYPING: mypy doesn't know about `.objects`. 22 | assert model.objects.count() > 0 # type: ignore[attr-defined] 23 | 24 | 25 | @pytest.mark.django_db 26 | def test_call_load_metadata_populate_client_vat_condition() -> None: 27 | """Test that load_metadata and populate methods work correctly.""" 28 | 29 | assert ClientVatCondition.objects.count() == 0 30 | management.call_command("afipmetadata") 31 | 32 | assert ClientVatCondition.objects.count() == 11 33 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.core.management import call_command 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_no_missing_migrations() -> None: 9 | """Check that there are no missing migrations of the app.""" 10 | 11 | # This returns pending migrations -- or false if non are pending. 12 | assert not call_command("makemigrations", check=True) 13 | -------------------------------------------------------------------------------- /tests/test_parsers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import date 4 | from datetime import datetime 5 | 6 | from django_afip import parsers 7 | from django_afip.clients import TZ_AR 8 | 9 | 10 | def test_parse_null_datetime() -> None: 11 | assert parsers.parse_datetime_maybe("NULL") is None 12 | 13 | 14 | def test_parse_none_datetime() -> None: 15 | assert parsers.parse_datetime_maybe(None) is None 16 | 17 | 18 | def test_parse_datetimes() -> None: 19 | assert parsers.parse_datetime("20170730154330") == datetime( 20 | 2017, 7, 30, 15, 43, 30, tzinfo=TZ_AR 21 | ) 22 | 23 | assert parsers.parse_datetime_maybe("20170730154330") == datetime( 24 | 2017, 7, 30, 15, 43, 30, tzinfo=TZ_AR 25 | ) 26 | 27 | 28 | def test_parse_null_date() -> None: 29 | assert parsers.parse_date_maybe("NULL") is None 30 | 31 | 32 | def test_parse_none_date() -> None: 33 | assert parsers.parse_date_maybe(None) is None 34 | 35 | 36 | def test_parse_dates() -> None: 37 | assert parsers.parse_date("20170730") == date(2017, 7, 30) 38 | assert parsers.parse_date_maybe("20170730") == date(2017, 7, 30) 39 | 40 | 41 | def test_weirdly_encoded() -> None: 42 | # This is the encoding AFIP sometimes uses: 43 | string = "Añadir paÃ\xads" 44 | assert parsers.parse_string(string) == "Añadir país" 45 | -------------------------------------------------------------------------------- /tests/test_pdf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | import re 5 | from datetime import date 6 | from unittest.mock import patch 7 | 8 | import pytest 9 | from django.core.paginator import Paginator 10 | 11 | from django_afip import factories 12 | from django_afip import models 13 | from django_afip.pdf import PdfBuilder 14 | from django_afip.pdf import ReceiptQrCode 15 | from django_afip.pdf import create_entries_context_for_render 16 | 17 | 18 | @pytest.mark.django_db 19 | def test_pdf_generation() -> None: 20 | """Test PDF file generation. 21 | 22 | For the moment, this test case mostly verifies that pdf generation 23 | *works*, but does not actually validate the pdf file itself. 24 | 25 | Running this locally *will* yield the file itself, which is useful for 26 | manual inspection. 27 | """ 28 | pdf = factories.ReceiptPDFFactory(receipt__receipt_number=3) 29 | factories.ReceiptValidationFactory(receipt=pdf.receipt) 30 | pdf.save_pdf() 31 | regex = r"afip/receipts/[a-f0-9]{2}/[a-f0-9]{2}/[a-f0-9]{32}.pdf" 32 | 33 | assert re.match(regex, pdf.pdf_file.name) 34 | assert pdf.pdf_file.name.endswith(".pdf") 35 | 36 | 37 | @pytest.mark.django_db 38 | def test_unauthorized_receipt_generation() -> None: 39 | """ 40 | Test PDF file generation for unauthorized receipts. 41 | 42 | Confirm that attempting to generate a PDF for an unauthorized receipt 43 | raises. 44 | """ 45 | taxpayer = factories.TaxPayerFactory() 46 | receipt = factories.ReceiptFactory( 47 | receipt_number=None, 48 | point_of_sales__owner=taxpayer, 49 | ) 50 | pdf = models.ReceiptPDF.objects.create_for_receipt( 51 | receipt=receipt, 52 | client_name="John Doe", 53 | client_address="12 Green Road\nGreenville\nUK", 54 | ) 55 | with pytest.raises( 56 | Exception, match="Cannot generate pdf for non-authorized receipt" 57 | ): 58 | pdf.save_pdf() 59 | 60 | 61 | @pytest.mark.django_db 62 | def test_signal_generation_for_not_validated_receipt() -> None: 63 | printable = factories.ReceiptPDFFactory() 64 | 65 | assert not (printable.pdf_file) 66 | 67 | 68 | @pytest.mark.django_db 69 | def test_qrcode_data() -> None: 70 | pdf = factories.ReceiptPDFFactory( 71 | receipt__receipt_number=3, 72 | receipt__issued_date=date(2021, 3, 2), 73 | ) 74 | factories.ReceiptValidationFactory(receipt=pdf.receipt) 75 | 76 | qrcode = ReceiptQrCode(pdf.receipt) 77 | assert qrcode._data == { 78 | "codAut": 67190616790549, 79 | "ctz": 1.0, 80 | "cuit": 20329642330, 81 | "fecha": "2021-03-02", 82 | "importe": 130.0, 83 | "moneda": "PES", 84 | "nroCmp": 3, 85 | "nroDocRec": 203012345, 86 | "ptoVta": 1, 87 | "tipoCmp": 6, 88 | "tipoCodAut": "E", 89 | "tipoDocRec": 96, 90 | "ver": 1, 91 | } 92 | 93 | 94 | @pytest.mark.django_db 95 | def test_create_entries_for_render() -> None: 96 | validation = factories.ReceiptValidationFactory() 97 | for _i in range(10): 98 | factories.ReceiptEntryFactory( 99 | receipt=validation.receipt, unit_price=1, quantity=1 100 | ) 101 | entries_queryset = models.ReceiptEntry.objects.all() 102 | paginator = Paginator(entries_queryset, 5) 103 | entries = create_entries_context_for_render(paginator) 104 | 105 | assert list(entries.keys()) == [1, 2] 106 | assert entries[1]["previous_subtotal"] == 0 107 | assert entries[1]["subtotal"] == 5 108 | assert list(entries[1]["entries"]) == list(models.ReceiptEntry.objects.all()[:5]) 109 | 110 | assert entries[2]["previous_subtotal"] == 5 111 | assert entries[2]["subtotal"] == 10 112 | assert list(entries[2]["entries"]) == list(models.ReceiptEntry.objects.all()[5:10]) 113 | 114 | 115 | @pytest.mark.django_db 116 | def test_receipt_pdf_modified_builder() -> None: 117 | validation = factories.ReceiptValidationFactory() 118 | validation.receipt.total_amount = 20 119 | validation.receipt.save() 120 | for _i in range(10): 121 | random.uniform(1.00, 12.5) 122 | factories.ReceiptEntryFactory( 123 | receipt=validation.receipt, unit_price=1, quantity=2 124 | ) 125 | 126 | printable = factories.ReceiptPDFFactory(receipt=validation.receipt) 127 | assert not printable.pdf_file 128 | 129 | printable.save_pdf(builder=PdfBuilder(entries_per_page=5)) 130 | assert printable.pdf_file 131 | assert printable.pdf_file.name.endswith(".pdf") 132 | 133 | 134 | @pytest.mark.django_db 135 | def test_receipt_pdf_call_function() -> None: 136 | validation = factories.ReceiptValidationFactory() 137 | for _i in range(80): 138 | price = random.uniform(1.00, 12.5) 139 | factories.ReceiptEntryFactory( 140 | receipt=validation.receipt, unit_price=price, quantity=2 141 | ) 142 | 143 | printable = factories.ReceiptPDFFactory(receipt=validation.receipt) 144 | with patch( 145 | "django_afip.pdf.create_entries_context_for_render", spec=True 146 | ) as mocked_call: 147 | printable.save_pdf(builder=PdfBuilder(entries_per_page=5)) 148 | 149 | assert mocked_call.called 150 | assert mocked_call.call_count == 1 151 | -------------------------------------------------------------------------------- /tests/test_tags.py: -------------------------------------------------------------------------------- 1 | """Tests for provided template tags.""" 2 | 3 | from __future__ import annotations 4 | 5 | from django_afip.templatetags.django_afip import format_cuit 6 | 7 | 8 | def test_format_cuit_tag_with_string() -> None: 9 | """Test valid string inputs.""" 10 | assert format_cuit("20329642330") == "20-32964233-0" 11 | assert format_cuit("20-32964233-0") == "20-32964233-0" 12 | 13 | 14 | def test_format_cuit_tag_with_number() -> None: 15 | """Test valid numerical input.""" 16 | assert format_cuit(20329642330) == "20-32964233-0" 17 | 18 | 19 | def test_format_cuit_tag_with_bad_string() -> None: 20 | """Test invalid string input.""" 21 | assert format_cuit("blah blah") == "blah blah" 22 | 23 | 24 | def test_format_cuit_tag_with_bad_number() -> None: 25 | """Test invalid numerical input.""" 26 | assert format_cuit(1234) == 1234 27 | -------------------------------------------------------------------------------- /tests/test_taxpayer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | 5 | import pytest 6 | from factory.django import FileField 7 | from freezegun import freeze_time 8 | from OpenSSL import crypto 9 | 10 | from django_afip import factories 11 | 12 | 13 | @pytest.mark.django_db 14 | def test_key_generation() -> None: 15 | taxpayer = factories.TaxPayerFactory.build(key=None) 16 | taxpayer.generate_key() 17 | 18 | key = taxpayer.key.file.read().decode() 19 | assert key.splitlines()[0] == "-----BEGIN PRIVATE KEY-----" 20 | assert key.splitlines()[-1] == "-----END PRIVATE KEY-----" 21 | 22 | loaded_key = crypto.load_privatekey(crypto.FILETYPE_PEM, key) 23 | assert isinstance(loaded_key, crypto.PKey) 24 | 25 | 26 | def test_dont_overwrite_keys() -> None: 27 | text = b"Hello! I'm not really a key :D" 28 | taxpayer = factories.TaxPayerFactory.build(key=FileField(data=text)) 29 | 30 | taxpayer.generate_key() 31 | key = taxpayer.key.read() 32 | 33 | assert text == key 34 | 35 | 36 | @pytest.mark.django_db 37 | def test_overwrite_keys_force() -> None: 38 | text = b"Hello! I'm not really a key :D" 39 | taxpayer = factories.TaxPayerFactory.build(key__data=text) 40 | 41 | taxpayer.generate_key(force=True) 42 | key = taxpayer.key.file.read().decode() 43 | 44 | assert text != key 45 | assert key.splitlines()[0] == "-----BEGIN PRIVATE KEY-----" 46 | assert key.splitlines()[-1] == "-----END PRIVATE KEY-----" 47 | 48 | loaded_key = crypto.load_privatekey(crypto.FILETYPE_PEM, key) 49 | assert isinstance(loaded_key, crypto.PKey) 50 | 51 | 52 | @freeze_time(datetime.fromtimestamp(1489537017)) 53 | @pytest.mark.django_db 54 | def test_csr_generation() -> None: 55 | taxpayer = factories.TaxPayerFactory.build(key=None) 56 | taxpayer.generate_key() 57 | 58 | csr_file = taxpayer.generate_csr() 59 | csr = csr_file.read().decode() 60 | 61 | assert csr.splitlines()[0] == "-----BEGIN CERTIFICATE REQUEST-----" 62 | 63 | assert csr.splitlines()[-1] == "-----END CERTIFICATE REQUEST-----" 64 | 65 | loaded_csr = crypto.load_certificate_request(crypto.FILETYPE_PEM, csr) 66 | assert isinstance(loaded_csr, crypto.X509Req) 67 | 68 | expected_components = [ 69 | (b"O", b"John Smith"), 70 | (b"CN", b"djangoafip1489537017"), 71 | (b"serialNumber", b"CUIT 20329642330"), 72 | ] 73 | 74 | assert expected_components == loaded_csr.get_subject().get_components() 75 | 76 | 77 | def test_certificate_object() -> None: 78 | taxpayer = factories.TaxPayerFactory.build() 79 | cert = taxpayer.certificate_object 80 | 81 | assert isinstance(cert, crypto.X509) 82 | 83 | 84 | def test_null_certificate_object() -> None: 85 | taxpayer = factories.TaxPayerFactory.build(certificate=None) 86 | cert = taxpayer.certificate_object 87 | 88 | assert cert is None 89 | 90 | 91 | def test_expiration_getter() -> None: 92 | taxpayer = factories.TaxPayerFactory.build(certificate=None) 93 | expiration = taxpayer.get_certificate_expiration() 94 | 95 | assert expiration is None 96 | 97 | 98 | def test_expiration_getter_no_cert() -> None: 99 | taxpayer = factories.TaxPayerFactory.build() 100 | expiration = taxpayer.get_certificate_expiration() 101 | 102 | assert isinstance(expiration, datetime) 103 | 104 | 105 | @pytest.mark.django_db 106 | def test_expiration_signal_update() -> None: 107 | taxpayer = factories.TaxPayerFactory(certificate_expiration=None) 108 | taxpayer.save() 109 | expiration = taxpayer.certificate_expiration 110 | 111 | assert isinstance(expiration, datetime) 112 | -------------------------------------------------------------------------------- /tests/test_transaction_protection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import MagicMock 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from django_afip import models 9 | from django_afip.factories import ReceiptFactory 10 | 11 | 12 | @pytest.fixture 13 | def disable_durability_check() -> None: 14 | """Disable the global fixture of the same name.""" 15 | 16 | 17 | @pytest.mark.django_db 18 | def test_raises() -> None: 19 | """Calling ``validate`` inside a transaction should raise.""" 20 | 21 | receipt = ReceiptFactory() 22 | queryset = models.Receipt.objects.filter(pk=receipt.pk) 23 | ticket = MagicMock() 24 | 25 | with ( 26 | patch( 27 | "django_afip.models.ReceiptQuerySet._assign_numbers", 28 | spec=True, 29 | ) as mocked_assign_numbers, 30 | pytest.raises(RuntimeError), 31 | ): 32 | # TYPING: django-stubs can't handle methods in querysets 33 | queryset.validate(ticket) # type: ignore[attr-defined] 34 | 35 | assert mocked_assign_numbers.call_count == 0 36 | -------------------------------------------------------------------------------- /tests/test_vattype.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from decimal import Decimal 4 | 5 | import pytest 6 | 7 | from django_afip.factories import VatTypeFactory 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_vat_type_as_decimal() -> None: 12 | assert VatTypeFactory().as_decimal == Decimal("0.21") 13 | assert VatTypeFactory(description="10.5%").as_decimal == Decimal("0.105") 14 | assert VatTypeFactory(description="0%").as_decimal == Decimal(0) 15 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import date 4 | 5 | import pytest 6 | from django.http import HttpResponse 7 | from django.test import Client 8 | from django.test import TestCase 9 | from django.urls import reverse 10 | from pytest_django.asserts import assertContains 11 | from pytest_django.asserts import assertHTMLEqual 12 | 13 | from django_afip import factories 14 | from django_afip import views 15 | 16 | 17 | class ReceiptPDFTestCase(TestCase): 18 | def test_html_view(self) -> None: 19 | """Test the HTML generation view.""" 20 | pdf = factories.ReceiptPDFFactory( 21 | receipt__concept__code=1, 22 | receipt__issued_date=date(2017, 5, 15), 23 | receipt__receipt_type__code=11, 24 | receipt__point_of_sales__owner__logo=None, 25 | ) 26 | factories.ReceiptValidationFactory(receipt=pdf.receipt) 27 | 28 | client = Client() 29 | response = client.get( 30 | "{}?html=true".format( 31 | reverse("receipt_displaypdf_view", args=(pdf.receipt.pk,)) 32 | ) 33 | ) 34 | 35 | assertHTMLEqual( 36 | response.content.decode(), 37 | """ 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | 52 | 103 | 104 |
105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 |
DescripciónCantidadPrecio UnitarioMonto
Total130.00
139 | 140 |
141 |
142 | 143 |
144 | 145 |

146 | CAE 147 | 67190616790549 148 | Vto CAE 149 | July 12, 2017 150 |

151 | 152 | Consultas de validez: 153 | 154 | https://www.afip.gob.ar/genericos/consultacae/ 155 | 156 |
157 | Teléfono Gratuito CABA, Área de Defensa y Protección al Consumidor. 158 | Tel 147 159 |
160 | 161 | Hoja 1 de 1 162 | 163 |
164 |
165 | 166 | 167 | 168 | 169 | 170 | """, # noqa: E501 # It's just long stuff. 171 | ) 172 | 173 | def test_logo_in_html(self) -> None: 174 | """Test the HTML generation view.""" 175 | pdf = factories.ReceiptPDFFactory() 176 | factories.ReceiptValidationFactory(receipt=pdf.receipt) 177 | 178 | client = Client() 179 | response = client.get( 180 | "{}?html=true".format( 181 | reverse("receipt_displaypdf_view", args=(pdf.receipt.pk,)) 182 | ) 183 | ) 184 | 185 | assert isinstance(response, HttpResponse) 186 | assertContains( 187 | response, 188 | """ 189 |
190 | Logo
191 | Alice Doe
192 | Happy Street 123, CABA
193 | 194 | Responsable Monotributo
195 |
196 | """, # noqa: E501 # It's just long stuff. :( 197 | html=True, 198 | ) 199 | 200 | def test_pdf_view(self) -> None: 201 | """ 202 | Test the PDF generation view. 203 | """ 204 | taxpayer = factories.TaxPayerFactory() 205 | 206 | pdf = factories.ReceiptPDFFactory( 207 | receipt__point_of_sales__owner=taxpayer, 208 | ) 209 | factories.ReceiptValidationFactory(receipt=pdf.receipt) 210 | 211 | client = Client() 212 | response = client.get(reverse("receipt_pdf_view", args=(pdf.receipt.pk,))) 213 | 214 | assert response.status_code == 200 215 | assert response.content[:7] == b"%PDF-1." 216 | 217 | headers = sorted(response.serialize_headers().decode().splitlines()) 218 | assert "Content-Type: application/pdf" in headers 219 | 220 | 221 | @pytest.mark.django_db 222 | def test_template_discovery(client: Client) -> None: 223 | taxpayer = factories.TaxPayerFactory(cuit="20329642330") 224 | pdf = factories.ReceiptPDFFactory( 225 | receipt__point_of_sales__owner=taxpayer, 226 | receipt__point_of_sales__number=9999, 227 | receipt__receipt_type__code=6, 228 | ) 229 | factories.ReceiptValidationFactory(receipt=pdf.receipt) 230 | 231 | client = Client() 232 | response = client.get( 233 | "{}?html=true".format( 234 | reverse("receipt_displaypdf_view", args=(pdf.receipt.pk,)) 235 | ) 236 | ) 237 | 238 | assert response.content == b"This is a dummy template to test template discovery.\n" 239 | 240 | 241 | class ReceiptPDFViewDownloadNameTestCase(TestCase): 242 | def test_download_name(self) -> None: 243 | factories.ReceiptFactory(pk=9, receipt_number=32) 244 | 245 | view = views.ReceiptPDFView() 246 | view.kwargs = {"pk": 9} 247 | 248 | assert view.get_download_name() == "0001-00000032.pdf" 249 | -------------------------------------------------------------------------------- /tests/test_webservices.py: -------------------------------------------------------------------------------- 1 | """Tests for AFIP-WS related classes.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import date 6 | from datetime import timedelta 7 | from unittest.mock import patch 8 | 9 | import pytest 10 | from django import VERSION as DJANGO_VERSION 11 | from django.core import management 12 | from factory.django import FileField 13 | 14 | if DJANGO_VERSION[0] < 5: 15 | from pytest_django.asserts import assertQuerysetEqual as assertQuerySetEqual 16 | else: 17 | from pytest_django.asserts import assertQuerySetEqual 18 | 19 | from django_afip import exceptions 20 | from django_afip import factories 21 | from django_afip import models 22 | 23 | 24 | @pytest.mark.live 25 | @pytest.mark.django_db 26 | def test_authentication_with_bad_cuit() -> None: 27 | """Test using the wrong cuit for a key pair.""" 28 | 29 | taxpayer = factories.AlternateTaxpayerFactory(cuit=20329642339) 30 | taxpayer.create_ticket("wsfe") 31 | 32 | with pytest.raises( 33 | exceptions.AfipException, 34 | # Note: AFIP apparently edited this message and added a typo: 35 | match="ValidacionDeToken: No apareci[oó] CUIT en lista de relaciones:", 36 | ): 37 | taxpayer.fetch_points_of_sales() 38 | 39 | 40 | @pytest.mark.live 41 | @pytest.mark.django_db 42 | def test_authentication_with_bogus_certificate_exception() -> None: 43 | """Test that using a junk ceritificates raises as expected.""" 44 | 45 | # New TaxPayers will fail to save with an invalid cert, but many 46 | # systems may have very old TaxPayers, externally created, or other 47 | # stuff, so this scenario might still be possible. 48 | with patch( 49 | "django_afip.models.TaxPayer.get_certificate_expiration", 50 | spec=True, 51 | return_value=None, 52 | ): 53 | taxpayer = factories.TaxPayerFactory( 54 | key=FileField(data=b"Blah"), 55 | certificate=FileField(data=b"Blah"), 56 | ) 57 | 58 | with pytest.raises(exceptions.CorruptCertificate) as e: 59 | taxpayer.create_ticket("wsfe") 60 | 61 | assert not isinstance(e, exceptions.AfipException) 62 | 63 | 64 | @pytest.mark.live 65 | @pytest.mark.django_db 66 | def test_authentication_with_no_active_taxpayer() -> None: 67 | """Test that no TaxPayers raises an understandable error.""" 68 | with pytest.raises( 69 | exceptions.AuthenticationError, 70 | match="There are no taxpayers to generate a ticket.", 71 | ): 72 | models.AuthTicket.objects.get_any_active("wsfe") 73 | 74 | 75 | @pytest.mark.live 76 | @pytest.mark.django_db 77 | def test_authentication_with_expired_certificate_exception() -> None: 78 | """Test that using an expired ceritificate raises as expected.""" 79 | taxpayer = factories.TaxPayerFactory( 80 | key=FileField(from_path=factories.get_test_file("test_expired.key")), 81 | certificate=FileField(from_path=factories.get_test_file("test_expired.crt")), 82 | ) 83 | 84 | with pytest.raises(exceptions.CertificateExpired): 85 | taxpayer.create_ticket("wsfe") 86 | 87 | 88 | @pytest.mark.live 89 | @pytest.mark.django_db 90 | def test_authentication_with_untrusted_certificate_exception() -> None: 91 | """ 92 | Test that using an untrusted ceritificate raises as expected. 93 | """ 94 | # Note that we hit production with a sandbox cert here: 95 | taxpayer = factories.TaxPayerFactory(is_sandboxed=False) 96 | 97 | with pytest.raises(exceptions.UntrustedCertificate): 98 | taxpayer.create_ticket("wsfe") 99 | 100 | 101 | @pytest.mark.django_db 102 | def test_population_command() -> None: 103 | """Test the afipmetadata command.""" 104 | management.call_command("afipmetadata") 105 | 106 | assert models.ReceiptType.objects.count() > 0 107 | assert models.ConceptType.objects.count() > 0 108 | assert models.DocumentType.objects.count() > 0 109 | assert models.VatType.objects.count() > 0 110 | assert models.TaxType.objects.count() > 0 111 | assert models.CurrencyType.objects.count() > 0 112 | 113 | 114 | @pytest.mark.django_db 115 | def test_metadata_deserialization() -> None: 116 | """Test that we deserialize descriptions properly.""" 117 | management.call_command("afipmetadata") 118 | 119 | # This assertion is tied to current data, but it validates that we 120 | # don't mess up encoding/decoding the value we get. 121 | # It _WILL_ need updating if the upstream value ever changes. 122 | fac_c = models.ReceiptType.objects.get(code=11) 123 | assert fac_c.description == "Factura C" 124 | 125 | 126 | @pytest.mark.django_db 127 | @pytest.mark.live 128 | def test_taxpayer_fetch_points_of_sale(populated_db: None) -> None: 129 | """Test the ``fetch_points_of_sales`` method.""" 130 | taxpayer = models.TaxPayer.objects.first() 131 | 132 | assert taxpayer is not None 133 | taxpayer.fetch_points_of_sales() 134 | 135 | points_of_sales = models.PointOfSales.objects.count() 136 | assert points_of_sales > 0 137 | 138 | 139 | @pytest.mark.django_db 140 | @pytest.mark.live 141 | def test_receipt_queryset_validate_empty(populated_db: None) -> None: 142 | factories.ReceiptFactory() 143 | 144 | # TYPING: django-stubs can't handle methods in querysets 145 | errs = models.Receipt.objects.none().validate() # type: ignore[attr-defined] 146 | 147 | assert errs == [] 148 | assert models.ReceiptValidation.objects.count() == 0 149 | 150 | 151 | @pytest.mark.django_db 152 | @pytest.mark.live 153 | def test_receipt_queryset_validation_good(populated_db: None) -> None: 154 | """Test validating valid receipts.""" 155 | r1 = factories.ReceiptWithVatAndTaxFactory() 156 | r2 = factories.ReceiptWithVatAndTaxFactory() 157 | r3 = factories.ReceiptWithVatAndTaxFactory() 158 | 159 | # TYPING: django-stubs can't handle methods in querysets 160 | errs = models.Receipt.objects.all().validate() # type: ignore[attr-defined] 161 | 162 | assert len(errs) == 0 163 | assert r1.validation.result == models.ReceiptValidation.RESULT_APPROVED 164 | assert r2.validation.result == models.ReceiptValidation.RESULT_APPROVED 165 | assert r3.validation.result == models.ReceiptValidation.RESULT_APPROVED 166 | assert models.ReceiptValidation.objects.count() == 3 167 | 168 | 169 | @pytest.mark.django_db 170 | @pytest.mark.live 171 | def test_receipt_queryset_validation_bad(populated_db: None) -> None: 172 | """Test validating invalid receipts.""" 173 | factories.ReceiptWithInconsistentVatAndTaxFactory() 174 | factories.ReceiptWithInconsistentVatAndTaxFactory() 175 | factories.ReceiptWithInconsistentVatAndTaxFactory() 176 | 177 | errs = models.Receipt.objects.all().validate() # type: ignore[attr-defined] 178 | 179 | assert len(errs) == 1 180 | assert errs[0] == ( 181 | "Error 10015: Factura B (CbteDesde igual a CbteHasta), DocTipo: " 182 | "80, DocNro 203012345 no se encuentra registrado en los padrones " 183 | "de AFIP y no corresponde a una cuit pais." 184 | ) 185 | 186 | assert models.ReceiptValidation.objects.count() == 0 187 | 188 | 189 | @pytest.mark.django_db 190 | @pytest.mark.live 191 | def test_receipt_queryset_validation_mixed(populated_db: None) -> None: 192 | """ 193 | Test validating a mixture of valid and invalid receipts. 194 | 195 | Receipts are validated by AFIP in-order, so all receipts previous to 196 | the bad one are validated, and nothing else is even parsed after the 197 | invalid one. 198 | """ 199 | r1 = factories.ReceiptWithVatAndTaxFactory() 200 | factories.ReceiptWithInconsistentVatAndTaxFactory() 201 | factories.ReceiptWithVatAndTaxFactory() 202 | 203 | errs = models.Receipt.objects.all().validate() # type: ignore[attr-defined] 204 | 205 | assert len(errs) == 1 206 | assert errs[0] == ( 207 | "Error 10015: Factura B (CbteDesde igual a CbteHasta), DocTipo: " 208 | "80, DocNro 203012345 no se encuentra registrado en los padrones " 209 | "de AFIP y no corresponde a una cuit pais." 210 | ) 211 | 212 | assertQuerySetEqual( 213 | models.ReceiptValidation.objects.all(), 214 | [r1.pk], 215 | lambda rv: rv.receipt_id, 216 | ) 217 | 218 | 219 | @pytest.mark.django_db 220 | @pytest.mark.live 221 | def test_receipt_queryset_validation_validated(populated_db: None) -> None: 222 | """Test validating invalid receipts.""" 223 | factories.ReceiptWithApprovedValidation() 224 | 225 | errs = models.Receipt.objects.all().validate() # type: ignore[attr-defined] 226 | 227 | assert models.ReceiptValidation.objects.count() == 1 228 | assert errs == [] 229 | 230 | 231 | @pytest.mark.django_db 232 | @pytest.mark.live 233 | def test_receipt_queryset_validation_good_service(populated_db: None) -> None: 234 | """Test validating a receipt for a service (rather than product).""" 235 | receipt = factories.ReceiptWithVatAndTaxFactory( 236 | concept__code=2, 237 | service_start=date.today() - timedelta(days=10), 238 | service_end=date.today(), 239 | expiration_date=date.today() + timedelta(days=10), 240 | ) 241 | 242 | errs = models.Receipt.objects.all().validate() # type: ignore[attr-defined] 243 | 244 | assert len(errs) == 0 245 | assert receipt.validation.result == models.ReceiptValidation.RESULT_APPROVED 246 | assert models.ReceiptValidation.objects.count() == 1 247 | 248 | 249 | @pytest.mark.django_db 250 | @pytest.mark.live 251 | def test_receipt_queryset_validation_good_without_tax(populated_db: None) -> None: 252 | """Test validating valid receipts.""" 253 | receipt = factories.ReceiptFactory( 254 | point_of_sales=models.PointOfSales.objects.first(), 255 | total_amount=121, 256 | ) 257 | factories.VatFactory(vat_type__code=5, receipt=receipt) 258 | 259 | errs = models.Receipt.objects.all().validate() # type: ignore[attr-defined] 260 | 261 | assert len(errs) == 0 262 | assert receipt.validation.result == models.ReceiptValidation.RESULT_APPROVED 263 | assert models.ReceiptValidation.objects.count() == 1 264 | 265 | 266 | @pytest.mark.django_db 267 | @pytest.mark.live 268 | def test_receipt_queryset_validation_good_without_vat(populated_db: None) -> None: 269 | """Test validating valid receipts.""" 270 | receipt = factories.ReceiptFactory( 271 | point_of_sales=models.PointOfSales.objects.first(), 272 | receipt_type__code=11, 273 | total_amount=109, 274 | ) 275 | factories.TaxFactory(tax_type__code=3, receipt=receipt) 276 | 277 | errs = models.Receipt.objects.all().validate() # type: ignore[attr-defined] 278 | 279 | assert len(errs) == 0 280 | assert receipt.validation.result == models.ReceiptValidation.RESULT_APPROVED 281 | assert models.ReceiptValidation.objects.count() == 1 282 | 283 | 284 | @pytest.mark.xfail(reason="Currently not working -- needs to get looked at.") 285 | @pytest.mark.django_db 286 | @pytest.mark.live 287 | def test_receipt_queryset_validation_with_observations(populated_db: None) -> None: 288 | receipt = factories.ReceiptFactory( 289 | document_number=20291144404, 290 | document_type__code=80, 291 | point_of_sales=models.PointOfSales.objects.first(), 292 | receipt_type__code=1, 293 | ) 294 | factories.VatFactory(vat_type__code=5, receipt=receipt) 295 | factories.TaxFactory(tax_type__code=3, receipt=receipt) 296 | 297 | errs = models.Receipt.objects.all().validate() # type: ignore[attr-defined] 298 | 299 | assert len(errs) == 0 300 | assert receipt.validation.result == models.ReceiptValidation.RESULT_APPROVED 301 | assert models.ReceiptValidation.objects.count() == 1 302 | assert models.Observation.objects.count() == 1 303 | assert receipt.validation.observations.count() == 1 304 | 305 | 306 | @pytest.mark.django_db 307 | @pytest.mark.live 308 | def test_receipt_queryset_credit_note(populated_db: None) -> None: 309 | """Test validating valid a credit note.""" 310 | # Create an invoice (code=6) and validate it... 311 | invoice = factories.ReceiptWithVatAndTaxFactory() 312 | 313 | qs = models.Receipt.objects.filter( 314 | pk=invoice.pk, 315 | ) 316 | errs = qs.validate() # type: ignore[attr-defined] 317 | assert len(errs) == 0 318 | assert models.ReceiptValidation.objects.count() == 1 319 | 320 | # Now create a credit note (code=8) and validate it... 321 | credit = factories.ReceiptWithVatAndTaxFactory(receipt_type__code=8) 322 | credit.related_receipts.set([invoice]) 323 | credit.save() 324 | 325 | qs = models.Receipt.objects.filter(pk=credit.pk) 326 | errs = qs.validate() # type: ignore[attr-defined] 327 | assert len(errs) == 0 328 | assert models.ReceiptValidation.objects.count() == 2 329 | 330 | 331 | @pytest.mark.django_db 332 | @pytest.mark.live 333 | def test_receipt_queryset_validation_good_with_client_vat_condition( 334 | populated_db: None, 335 | ) -> None: 336 | """Test validating valid receipts.""" 337 | receipt = factories.ReceiptWithClientVatConditionFactory() 338 | 339 | errs = models.Receipt.objects.all().validate() # type: ignore[attr-defined] 340 | 341 | assert len(errs) == 0 342 | assert receipt.validation.result == models.ReceiptValidation.RESULT_APPROVED 343 | assert models.ReceiptValidation.objects.count() == 1 344 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = sqlite,mypy,docs,ruff,vermin, 3 | 4 | [testenv] 5 | extras = 6 | dev 7 | postgres: postgres 8 | mysql: mysql 9 | deps = 10 | django42: Django>=4.0,<4.2 11 | django50: Django>=5.0,<5.1 12 | commands = pytest -vv -m "not live" {posargs} 13 | setenv = 14 | PYTHONPATH={toxinidir} 15 | sqlite: DATABASE_URL=sqlite:///:memory: 16 | mysql: DATABASE_URL={env:DATABASE_URL:mysql://root:mysql@127.0.0.1:3306/mysql} 17 | postgres: DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres 18 | passenv = 19 | GENTESTCSR 20 | 21 | # Hint: quickly run a one-shot container with: 22 | # docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 -it postgres 23 | [testenv:live] 24 | extras = dev, postgres 25 | commands = pytest -vv -m "live" {posargs} 26 | setenv = 27 | PYTHONPATH={toxinidir} 28 | DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres 29 | 30 | [testenv:makemigrations] 31 | extras = dev 32 | commands = django-admin makemigrations 33 | setenv = 34 | PYTHONPATH={toxinidir} 35 | DATABASE_URL=sqlite:///:memory: 36 | DJANGO_SETTINGS_MODULE=testapp.settings 37 | 38 | [testenv:fixtures] 39 | commands = python scripts/dump_metadata.py 40 | setenv = 41 | PYTHONPATH={toxinidir} 42 | DATABASE_URL=sqlite:///:memory: 43 | DJANGO_SETTINGS_MODULE=testapp.settings 44 | 45 | [testenv:mypy] 46 | # This breaks too often due to minor version upgrades of related packages. 47 | # It's unreliable and we can't afford to let it block CI. 48 | ignore_outcome = true 49 | extras = dev 50 | commands = mypy . 51 | 52 | [testenv:docs] 53 | extras = docs 54 | commands = make -C docs html 55 | allowlist_externals = make 56 | 57 | [testenv:ruff] 58 | # See: https://github.com/astral-sh/ruff/issues/14698 59 | deps = ruff!=0.8.1 60 | commands = 61 | ruff format . 62 | ruff check --force-exclude --fix --exit-non-zero-on-fix 63 | skip_install = true 64 | 65 | [testenv:vermin] 66 | deps = vermin 67 | commands = 68 | vermin -t=3.9- --backport zoneinfo --violations . 69 | skip_install = true 70 | --------------------------------------------------------------------------------