diff --git a/.gitignore b/.gitignore index 0105e6ed..a737f2cc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ django_afip/version.py testapp/test_ticket.yaml test.csr test2.csr + +#Branch files +Pipfile +Pipfile.lock diff --git a/django_afip/admin.py b/django_afip/admin.py index d427f6f6..24d2fa4d 100644 --- a/django_afip/admin.py +++ b/django_afip/admin.py @@ -498,3 +498,4 @@ def successful(self, obj): admin.site.register(models.VatType) admin.site.register(models.TaxType) admin.site.register(models.Observation) +admin.site.register(models.Caea) diff --git a/django_afip/exceptions.py b/django_afip/exceptions.py index 349ea926..3aaf08cc 100644 --- a/django_afip/exceptions.py +++ b/django_afip/exceptions.py @@ -63,3 +63,7 @@ class CannotValidateTogether(DjangoAfipException): class ValidationError(DjangoAfipException): """Raised when a single Receipt failed to validate with AFIP's WS.""" + + +class CaeaCountError(DjangoAfipException): + """Raised when query the caea to obtain the number but 0 or 2 or more.""" diff --git a/django_afip/factories.py b/django_afip/factories.py index 790fd2d4..57179034 100644 --- a/django_afip/factories.py +++ b/django_afip/factories.py @@ -1,7 +1,9 @@ +import calendar from datetime import date from datetime import datetime from pathlib import Path +import pytest from django.contrib.auth.models import User from django.utils.timezone import make_aware from factory import LazyFunction @@ -22,6 +24,40 @@ def get_test_file(filename: str, mode="r") -> Path: return path +def get_current_order() -> int: + """ + Helper method to detect if the day of the month + corresponds to the first quarter (1) or the second (2) + """ + today = datetime.now() + if today.day > 15: + return 2 + return 1 + + +def valid_since_caea(): + """ + Helper method to assign the valid_since field from Caea model + to the correspondent year,month,day in the quarter + """ + order = get_current_order() + if order == 2: + return datetime(datetime.now().year, datetime.now().month, 16) + return datetime(datetime.now().year, datetime.now().month, 1) + + +def expires_caea(): + """ + Helper method to assign the expires field from Caea model + to the correspondent year,month,day in the quarter + """ + order = get_current_order() + if order == 2: + final = calendar.monthrange(datetime.now().year, datetime.now().month)[1] + return datetime(datetime.now().year, datetime.now().month, final) + return datetime(datetime.now().year, datetime.now().month, 15) + + class UserFactory(DjangoModelFactory): class Meta: model = User @@ -120,6 +156,12 @@ class Meta: sales_terms = "Credit Card" +class PointOfSalesFactoryCaea(PointOfSalesFactory): + + number = 4 + issuance_type = "CAEA" + + class ReceiptFactory(DjangoModelFactory): class Meta: model = models.Receipt @@ -149,6 +191,15 @@ def post(obj: models.Receipt, create, extracted, **kwargs): TaxFactory(tax_type__code=3, receipt=obj) +class ReceiptWithVatAndTaxFactoryCaea(ReceiptFactory): + """Receipt with a valid Vat and Tax, ready to validate.""" + + @post_generation + def post(obj: models.Receipt, create, extracted, **kwargs): + VatFactory(vat_type__code=5, receipt=obj) + TaxFactory(tax_type__code=3, receipt=obj) + + class ReceiptWithInconsistentVatAndTaxFactory(ReceiptWithVatAndTaxFactory): """Receipt with a valid Vat and Tax, ready to validate.""" @@ -243,6 +294,20 @@ class Meta: tax_type = SubFactory(TaxTypeFactory) +class CaeaFactory(DjangoModelFactory): + class Meta: + model = models.Caea + + caea_code = "12345678974125" + period = datetime.today().strftime("%Y%m") + order = LazyFunction(get_current_order) + valid_since = LazyFunction(valid_since_caea) + expires = LazyFunction(expires_caea) + generated = make_aware(datetime(2022, 5, 30, 21, 6, 4)) + report_deadline = make_aware(datetime(2022, 6, 20)) + taxpayer = SubFactory(TaxPayerFactory) + + class ReceiptEntryFactory(DjangoModelFactory): class Meta: model = models.ReceiptEntry diff --git a/django_afip/migrations/0011_caea.py b/django_afip/migrations/0011_caea.py new file mode 100644 index 00000000..5a25dc19 --- /dev/null +++ b/django_afip/migrations/0011_caea.py @@ -0,0 +1,78 @@ +# Generated by Django 4.1.1 on 2022-09-06 15:02 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0010_alter_authticket_service"), + ] + + operations = [ + migrations.CreateModel( + name="Caea", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "caea_code", + models.BigIntegerField( + help_text="CAEA code to operate offline AFIP", + unique=True, + validators=[ + django.core.validators.RegexValidator(regex="[0-9]{14}") + ], + ), + ), + ( + "period", + models.IntegerField( + help_text="Period to send in the CAEA request (yyyymm)" + ), + ), + ( + "order", + models.IntegerField( + choices=[(1, "1"), (2, "2")], + help_text="Month is divided in 1st quarter or 2nd quarter", + ), + ), + ("valid_since", models.DateTimeField(verbose_name="valid_to")), + ("expires", models.DateTimeField(verbose_name="expires")), + ("generated", models.DateTimeField(verbose_name="generated")), + ( + "final_date_inform", + models.DateTimeField(verbose_name="final_date_inform"), + ), + ( + "service", + models.CharField( + help_text="Service for which this ticket has been authorized.", + max_length=34, + verbose_name="service", + ), + ), + ("active", models.BooleanField(default=False)), + ( + "taxpayer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="caea_tickets", + to="afip.taxpayer", + verbose_name="taxpayer", + ), + ), + ], + ), + ] diff --git a/django_afip/migrations/0012_alter_caea_caea_code.py b/django_afip/migrations/0012_alter_caea_caea_code.py new file mode 100644 index 00000000..792ad6db --- /dev/null +++ b/django_afip/migrations/0012_alter_caea_caea_code.py @@ -0,0 +1,28 @@ +# Generated by Django 4.1.1 on 2022-09-06 15:15 + +import django.core.validators +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0011_caea"), + ] + + operations = [ + migrations.AlterField( + model_name="caea", + name="caea_code", + field=models.BigIntegerField( + help_text="CAEA code to operate offline AFIP", + unique=True, + validators=[ + django.core.validators.RegexValidator(regex="[0-9]{14}"), + django.core.validators.MinLengthValidator(14), + django.core.validators.MaxLengthValidator(14), + ], + ), + ), + ] diff --git a/django_afip/migrations/0013_alter_caea_caea_code.py b/django_afip/migrations/0013_alter_caea_caea_code.py new file mode 100644 index 00000000..625dd05f --- /dev/null +++ b/django_afip/migrations/0013_alter_caea_caea_code.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.1 on 2022-09-06 15:24 + +import django.core.validators +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0012_alter_caea_caea_code"), + ] + + operations = [ + migrations.AlterField( + model_name="caea", + name="caea_code", + field=models.PositiveBigIntegerField( + help_text="CAEA code to operate offline AFIP", + unique=True, + validators=[ + django.core.validators.RegexValidator(regex="[0-9]{14}"), + django.core.validators.MaxValueValidator(99999999999999), + ], + ), + ), + ] diff --git a/django_afip/migrations/0014_remove_caea_service.py b/django_afip/migrations/0014_remove_caea_service.py new file mode 100644 index 00000000..868b2f29 --- /dev/null +++ b/django_afip/migrations/0014_remove_caea_service.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.1 on 2022-09-06 21:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0013_alter_caea_caea_code"), + ] + + operations = [ + migrations.RemoveField( + model_name="caea", + name="service", + ), + ] diff --git a/django_afip/migrations/0015_alter_caea_expires_alter_caea_final_date_inform_and_more.py b/django_afip/migrations/0015_alter_caea_expires_alter_caea_final_date_inform_and_more.py new file mode 100644 index 00000000..f5d1e207 --- /dev/null +++ b/django_afip/migrations/0015_alter_caea_expires_alter_caea_final_date_inform_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.1.1 on 2022-09-06 21:43 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0014_remove_caea_service"), + ] + + operations = [ + migrations.AlterField( + model_name="caea", + name="expires", + field=models.DateField(verbose_name="expires"), + ), + migrations.AlterField( + model_name="caea", + name="final_date_inform", + field=models.DateField(verbose_name="final_date_inform"), + ), + migrations.AlterField( + model_name="caea", + name="valid_since", + field=models.DateField(verbose_name="valid_to"), + ), + ] diff --git a/django_afip/migrations/0016_receipt_generated.py b/django_afip/migrations/0016_receipt_generated.py new file mode 100644 index 00000000..c2edd5d6 --- /dev/null +++ b/django_afip/migrations/0016_receipt_generated.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.1 on 2022-09-07 13:01 + +import django.utils.timezone +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0015_alter_caea_expires_alter_caea_final_date_inform_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="receipt", + name="generated", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Time when the receipt was created", + ), + preserve_default=False, + ), + ] diff --git a/django_afip/migrations/0017_receipt_caea_receiptvalidation_caea.py b/django_afip/migrations/0017_receipt_caea_receiptvalidation_caea.py new file mode 100644 index 00000000..0f9e699e --- /dev/null +++ b/django_afip/migrations/0017_receipt_caea_receiptvalidation_caea.py @@ -0,0 +1,35 @@ +# Generated by Django 4.1.1 on 2022-09-08 19:18 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0016_receipt_generated"), + ] + + operations = [ + migrations.AddField( + model_name="receipt", + name="caea", + field=models.ForeignKey( + blank=True, + help_text="CAEA in case that the receipt must contain it", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="afip.caea", + ), + ), + migrations.AddField( + model_name="receiptvalidation", + name="caea", + field=models.BooleanField( + default=False, + help_text="Indicate if the validation was from a CAEA receipt, in that case the field cae contains the CAEA number", + verbose_name="is_caea", + ), + ), + ] diff --git a/django_afip/migrations/0018_caeacounter_and_more.py b/django_afip/migrations/0018_caeacounter_and_more.py new file mode 100644 index 00000000..db890079 --- /dev/null +++ b/django_afip/migrations/0018_caeacounter_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 4.1.1 on 2022-09-08 20:18 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0017_receipt_caea_receiptvalidation_caea"), + ] + + operations = [ + migrations.CreateModel( + name="CaeaCounter", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("value", models.BigIntegerField(default=1)), + ( + "pos", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="counter", + to="afip.pointofsales", + ), + ), + ( + "receipt_type", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="counter", + to="afip.receipttype", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="caeacounter", + constraint=models.UniqueConstraint( + fields=("pos", "receipt_type"), + name="unique_migration_pos_receipt_combination", + ), + ), + ] diff --git a/django_afip/migrations/0019_rename_value_caeacounter_next_value.py b/django_afip/migrations/0019_rename_value_caeacounter_next_value.py new file mode 100644 index 00000000..7d2245db --- /dev/null +++ b/django_afip/migrations/0019_rename_value_caeacounter_next_value.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.1 on 2022-09-08 20:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0018_caeacounter_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="caeacounter", + old_name="value", + new_name="next_value", + ), + ] diff --git a/django_afip/migrations/0020_alter_receipt_caea.py b/django_afip/migrations/0020_alter_receipt_caea.py new file mode 100644 index 00000000..13606e9b --- /dev/null +++ b/django_afip/migrations/0020_alter_receipt_caea.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.1 on 2022-09-13 18:35 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0019_rename_value_caeacounter_next_value"), + ] + + operations = [ + migrations.AlterField( + model_name="receipt", + name="caea", + field=models.ForeignKey( + blank=True, + help_text="CAEA in case that the receipt must contain it", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="caea", + to="afip.caea", + ), + ), + ] diff --git a/django_afip/migrations/0021_alter_receiptvalidation_cae_expiration.py b/django_afip/migrations/0021_alter_receiptvalidation_cae_expiration.py new file mode 100644 index 00000000..dc3120b0 --- /dev/null +++ b/django_afip/migrations/0021_alter_receiptvalidation_cae_expiration.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.1 on 2022-09-13 19:54 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0020_alter_receipt_caea"), + ] + + operations = [ + migrations.AlterField( + model_name="receiptvalidation", + name="cae_expiration", + field=models.DateField( + blank=True, + help_text="The CAE expiration as returned by the AFIP.", + null=True, + verbose_name="cae expiration", + ), + ), + ] diff --git a/django_afip/migrations/0022_alter_receiptvalidation_caea.py b/django_afip/migrations/0022_alter_receiptvalidation_caea.py new file mode 100644 index 00000000..da8cea35 --- /dev/null +++ b/django_afip/migrations/0022_alter_receiptvalidation_caea.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.7 on 2022-09-16 13:54 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0021_alter_receiptvalidation_cae_expiration"), + ] + + operations = [ + migrations.AlterField( + model_name="receiptvalidation", + name="caea", + field=models.BooleanField( + default=False, + help_text="Indicate if the validation was from a CAEA receipt, in that case the field CAE contains the CAEA number", + verbose_name="is_caea", + ), + ), + ] diff --git a/django_afip/migrations/0023_alter_receipt_caea.py b/django_afip/migrations/0023_alter_receipt_caea.py new file mode 100644 index 00000000..afd4aace --- /dev/null +++ b/django_afip/migrations/0023_alter_receipt_caea.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.1 on 2022-09-16 19:05 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0022_alter_receiptvalidation_caea"), + ] + + operations = [ + migrations.AlterField( + model_name="receipt", + name="caea", + field=models.ForeignKey( + blank=True, + help_text="CAEA in case that the receipt must contain it", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="receipts", + to="afip.caea", + ), + ), + ] diff --git a/django_afip/migrations/0024_informedcaeas_and_more.py b/django_afip/migrations/0024_informedcaeas_and_more.py new file mode 100644 index 00000000..d8cc0940 --- /dev/null +++ b/django_afip/migrations/0024_informedcaeas_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 4.1.1 on 2022-09-21 20:45 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0023_alter_receipt_caea"), + ] + + operations = [ + migrations.CreateModel( + name="InformedCaeas", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("processed_date", models.DateTimeField(verbose_name="processed date")), + ( + "caea", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="informed", + to="afip.caea", + ), + ), + ( + "pos", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="informed", + to="afip.pointofsales", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="informedcaeas", + constraint=models.UniqueConstraint( + fields=("pos", "caea"), name="unique_migration_pos_caea_combination" + ), + ), + ] diff --git a/django_afip/migrations/0025_alter_informedcaeas_processed_date.py b/django_afip/migrations/0025_alter_informedcaeas_processed_date.py new file mode 100644 index 00000000..7405b1f9 --- /dev/null +++ b/django_afip/migrations/0025_alter_informedcaeas_processed_date.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.1 on 2022-09-21 21:04 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0024_informedcaeas_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="informedcaeas", + name="processed_date", + field=models.DateField(verbose_name="processed date"), + ), + ] diff --git a/django_afip/migrations/0026_merge_20220922_2245.py b/django_afip/migrations/0026_merge_20220922_2245.py new file mode 100644 index 00000000..43d1fd52 --- /dev/null +++ b/django_afip/migrations/0026_merge_20220922_2245.py @@ -0,0 +1,13 @@ +# Generated by Django 4.1.1 on 2022-09-22 22:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0011_receiptentry_discount_and_more"), + ("afip", "0025_alter_informedcaeas_processed_date"), + ] + + operations = [] diff --git a/django_afip/migrations/0027_alter_caea_generated_alter_caea_unique_together.py b/django_afip/migrations/0027_alter_caea_generated_alter_caea_unique_together.py new file mode 100644 index 00000000..d861f1f0 --- /dev/null +++ b/django_afip/migrations/0027_alter_caea_generated_alter_caea_unique_together.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.8 on 2022-10-15 23:17 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0026_merge_20220922_2245"), + ] + + operations = [ + migrations.AlterField( + model_name="caea", + name="generated", + field=models.DateTimeField( + help_text="When this CAEA was generated", verbose_name="generated" + ), + ), + migrations.AlterUniqueTogether( + name="caea", + unique_together={("order", "period", "taxpayer")}, + ), + ] diff --git a/django_afip/migrations/0028_alter_receipt_options.py b/django_afip/migrations/0028_alter_receipt_options.py new file mode 100644 index 00000000..566091da --- /dev/null +++ b/django_afip/migrations/0028_alter_receipt_options.py @@ -0,0 +1,26 @@ +# Generated by Django 4.0.8 on 2022-10-15 23:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0027_alter_caea_generated_alter_caea_unique_together"), + ] + + operations = [ + migrations.AlterModelOptions( + name="receipt", + options={ + "ordering": ( + "issued_date", + "point_of_sales_id", + "receipt_number", + "pk", + ), + "verbose_name": "receipt", + "verbose_name_plural": "receipts", + }, + ), + ] diff --git a/django_afip/migrations/0029_alter_receipt_generated.py b/django_afip/migrations/0029_alter_receipt_generated.py new file mode 100644 index 00000000..8243cadd --- /dev/null +++ b/django_afip/migrations/0029_alter_receipt_generated.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.1 on 2022-10-17 15:30 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0028_alter_receipt_options"), + ] + + operations = [ + migrations.AlterField( + model_name="receipt", + name="generated", + field=models.DateTimeField( + auto_now_add=True, + null=True, + verbose_name="Time when the receipt was created", + ), + ), + ] diff --git a/django_afip/migrations/0030_remove_caea_final_date_inform_caea_report_deadline.py b/django_afip/migrations/0030_remove_caea_final_date_inform_caea_report_deadline.py new file mode 100644 index 00000000..e5d527e1 --- /dev/null +++ b/django_afip/migrations/0030_remove_caea_final_date_inform_caea_report_deadline.py @@ -0,0 +1,30 @@ +# Generated by Django 4.1.1 on 2022-10-17 15:40 + +import datetime + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0029_alter_receipt_generated"), + ] + + operations = [ + migrations.RemoveField( + model_name="caea", + name="final_date_inform", + ), + migrations.AddField( + model_name="caea", + name="report_deadline", + field=models.DateField( + default=datetime.datetime(2022, 10, 17, 15, 40, 36, 849667), + help_text="Activities for this CAEA must be informed before this date", + verbose_name="report deadline", + ), + preserve_default=False, + ), + ] diff --git a/django_afip/migrations/0031_alter_caea_managers.py b/django_afip/migrations/0031_alter_caea_managers.py new file mode 100644 index 00000000..1af67139 --- /dev/null +++ b/django_afip/migrations/0031_alter_caea_managers.py @@ -0,0 +1,20 @@ +# Generated by Django 4.0.8 on 2022-10-17 22:35 + +import django.db.models.manager +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0030_remove_caea_final_date_inform_caea_report_deadline"), + ] + + operations = [ + migrations.AlterModelManagers( + name="caea", + managers=[ + ("actives_caea", django.db.models.manager.Manager()), + ], + ), + ] diff --git a/django_afip/migrations/0032_alter_caea_managers_remove_caea_active.py b/django_afip/migrations/0032_alter_caea_managers_remove_caea_active.py new file mode 100644 index 00000000..f2902050 --- /dev/null +++ b/django_afip/migrations/0032_alter_caea_managers_remove_caea_active.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1.1 on 2022-10-17 23:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0031_alter_caea_managers"), + ] + + operations = [ + migrations.AlterModelManagers( + name="caea", + managers=[], + ), + migrations.RemoveField( + model_name="caea", + name="active", + ), + ] diff --git a/django_afip/migrations/0033_delete_caeacounter.py b/django_afip/migrations/0033_delete_caeacounter.py new file mode 100644 index 00000000..db7b2b33 --- /dev/null +++ b/django_afip/migrations/0033_delete_caeacounter.py @@ -0,0 +1,16 @@ +# Generated by Django 4.0.8 on 2022-10-18 20:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("afip", "0032_alter_caea_managers_remove_caea_active"), + ] + + operations = [ + migrations.DeleteModel( + name="CaeaCounter", + ), + ] diff --git a/django_afip/models.py b/django_afip/models.py index 5455a704..4c20867f 100644 --- a/django_afip/models.py +++ b/django_afip/models.py @@ -19,7 +19,9 @@ from django.core import management from django.core.files import File from django.core.files.storage import Storage +from django.core.validators import MaxValueValidator from django.core.validators import MinValueValidator +from django.core.validators import RegexValidator from django.db import connection from django.db import models from django.db.models import CheckConstraint @@ -27,6 +29,7 @@ from django.db.models import F from django.db.models import Q from django.db.models import Sum +from django.utils.dateparse import parse_date from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ from django_renderpdf.helpers import render_pdf @@ -119,6 +122,17 @@ def _get_storage_from_settings(setting_name: str) -> Storage: return import_string(path) +def caea_is_active(valid_since, valid_to) -> bool: + valid_since = parsers.parse_date(valid_since) + valid_to = parsers.parse_date(valid_to) + today = datetime.now().date() + + if valid_since <= today <= valid_to: + return True + else: + return False + + class GenericAfipTypeManager(models.Manager): """Default Manager for GenericAfipType.""" @@ -543,6 +557,88 @@ def fetch_points_of_sales( return results + def request_new_caea( + self, + period: int = None, + order: int = None, + ticket: AuthTicket = None, + ) -> Caea: + """ + Get a CAEA code for the TaxPayer, if a CAEA alredy exists the check_response will raise an exception + + Returns a caea object. + """ + ticket = ticket or self.get_or_create_ticket("wsfe") + + client = clients.get_client("wsfe", self.is_sandboxed) + + response = client.service.FECAEASolicitar( + serializers.serialize_ticket(ticket), + Periodo=serializers.serialize_caea_period(period), + Orden=serializers.serialize_caea_order(order), + ) + check_response( + response + ) # be aware that this func raise an error if it's present + + caea = Caea.objects.create( + caea_code=int(response.ResultGet.CAEA), + period=int(response.ResultGet.Periodo), + order=int(response.ResultGet.Orden), + valid_since=parsers.parse_date(response.ResultGet.FchVigDesde), + expires=parsers.parse_date(response.ResultGet.FchVigHasta), + generated=parsers.parse_datetime(response.ResultGet.FchProceso), + report_deadline=parsers.parse_date(response.ResultGet.FchTopeInf), + taxpayer=self, + ) + return caea + + def fetch_caea( + self, + period: str = None, + order: int = None, + ticket: AuthTicket = None, + ) -> Caea: + """ + Consult the CAEA code given by AFIP, if for some reason the code it's not saved in the db it will be created + + Returns a CAEA model. + """ + ticket = ticket or self.get_or_create_ticket("wsfe") + + client = clients.get_client("wsfe", self.is_sandboxed) + response = client.service.FECAEAConsultar( + serializers.serialize_ticket(ticket), + Periodo=serializers.serialize_caea_period(period), + Orden=serializers.serialize_caea_order(order), + ) + + check_response( + response + ) # be aware that this func raise an error if it's present + update = { + "caea_code": int(response.ResultGet.CAEA), + "period": int(response.ResultGet.Periodo), + "order": int(response.ResultGet.Orden), + "valid_since": parsers.parse_date(response.ResultGet.FchVigDesde), + "expires": parsers.parse_date(response.ResultGet.FchVigHasta), + "generated": parsers.parse_datetime(response.ResultGet.FchProceso), + "report_deadline": parsers.parse_date(response.ResultGet.FchTopeInf), + "taxpayer": self, + } + + caea = Caea.objects.update_or_create( + caea_code=int(response.ResultGet.CAEA), defaults=update + ) + + return caea + + def fetch_or_create_caea(self, period: int, order: int): + try: + self.request_new_caea(period=period, order=order) + except: + self.fetch_caea(period=period, order=order) + def __repr__(self) -> str: return "".format( self.pk, @@ -558,6 +654,143 @@ class Meta: verbose_name_plural = _("taxpayers") +class CaeaQuerySet(models.QuerySet): + def active(self): + today = datetime.today() + return self.filter(valid_since__lte=today, expires__gte=today) + + +class Caea(models.Model): + """Represents a CAEA code to continue operating when AFIP is offline. + + The methods provideed by AFIP like: consulting CAEA or informing a Receipt will be attached the TaxPayer model. + + """ + + caea_code = models.PositiveBigIntegerField( + validators=[ + RegexValidator(regex="[0-9]{14}"), + MaxValueValidator(99999999999999), + ], + help_text=_("CAEA code to operate offline AFIP"), + unique=True, + null=False, + blank=False, + ) + + period = models.IntegerField( + help_text=_("Period to send in the CAEA request (yyyymm)") + ) + + order = models.IntegerField( + choices=[(1, "1"), (2, "2")], + help_text=_("Month is divided in 1st quarter or 2nd quarter"), + ) + + valid_since = models.DateField( + _("valid_to"), + ) + expires = models.DateField( + _("expires"), + ) + + generated = models.DateTimeField( + _("generated"), + help_text=_("When this CAEA was generated"), + ) + report_deadline = models.DateField( + _("report deadline"), + help_text=_("Activities for this CAEA must be informed before this date"), + ) + + taxpayer = models.ForeignKey( + TaxPayer, + verbose_name=_("taxpayer"), + related_name="caea_tickets", + on_delete=models.CASCADE, + ) + + objects = CaeaQuerySet.as_manager() + + @property + def is_active(self) -> bool: + today = datetime.today() + return self.valid_since <= today <= self.expires + + def __str__(self) -> str: + return str(self.caea_code) + + class Meta: + unique_together = ("order", "period", "taxpayer") + + def _inform_caea_without_operations( + self, + pos: PointOfSales, + ticket: AuthTicket = None, + ) -> InformedCaeas: + """ + Inform to AFIP that the PointOfSales and CAEA passed have not any movement between the duration of the CAEA + """ + ticket = ticket or pos.owner.get_or_create_ticket("wsfe") + + client = clients.get_client("wsfe", pos.owner.is_sandboxed) + response = client.service.FECAEASinMovimientoInformar( + serializers.serialize_ticket(ticket), + PtoVta=pos.number, + CAEA=self.caea_code, + ) + + check_response( + response + ) # be aware that this func raise an error if it's present + registry = InformedCaeas.objects.create( + pos=pos, + caea=self, + processed_date=datetime.strptime(response.FchProceso, "%Y%m%d").date(), + ) + return registry + + def consult_caea_without_operations( + self, + pos: PointOfSales, + ticket: AuthTicket = None, + ) -> InformedCaeas or None: + """ + Consult the state of the CAEA with AFIP, if the consult raise an error (probably CAEA without movement was never informed) + the method handle this an inform to AFIP the CAEA and POS + """ + + try: + registry = InformedCaeas.objects.get(pos=pos, caea=self.caea_code) + return registry + except InformedCaeas.DoesNotExist: + registry = None + + ticket = ticket or pos.owner.get_or_create_ticket("wsfe") + + client = clients.get_client("wsfe", pos.owner.is_sandboxed) + response = client.service.FECAEASinMovimientoConsultar( + serializers.serialize_ticket(ticket), + PtoVta=pos.number, + CAEA=self.caea_code, + ) + try: + check_response( + response + ) # be aware that this func raise an error if it's present + + # if for some reason a CAEA code was informed into AFIP DB but we have not a InformedCAEA this solve that + registry = InformedCaeas.objects.create( + pos=pos, + caea=self, + processed_date=datetime.strptime(response.FchProceso, "%Y%m%d").date(), + ) + return registry + except exceptions.AfipException: + registry = self._inform_caea_without_operations(pos=pos, ticket=ticket) + return registry + + class PointOfSales(models.Model): """ Represents an existing AFIP point of sale. @@ -903,15 +1136,20 @@ def validate(self, ticket: AuthTicket = None) -> list[str]: qs = self.filter(validation__isnull=True).check_groupable() if qs.count() == 0: return [] - qs.order_by("issued_date", "id")._assign_numbers() + + pos = qs[0].point_of_sales.issuance_type == "CAEA" + + if pos: + qs.order_by("issued_date", "id") + else: + qs.order_by("issued_date", "id")._assign_numbers() return qs._validate(ticket) - def _validate(self, ticket=None) -> list[str]: - first = self.first() - assert first is not None # should never happen; mostly a hint for mypy - ticket = ticket or first.point_of_sales.owner.get_or_create_ticket("wsfe") - client = clients.get_client("wsfe", first.point_of_sales.owner.is_sandboxed) + def _validate_with_cae(self, client, ticket): + """ + A helper method to validate the Receipt made with CAE + """ response = client.service.FECAESolicitar( serializers.serialize_ticket(ticket), serializers.serialize_multiple_receipts(self), @@ -946,10 +1184,62 @@ def _validate(self, ticket=None) -> list[str]: parsers.parse_string(obs.Msg), ) ) - # Remove the number from ones that failed to validate: self.filter(validation__isnull=True).update(receipt_number=None) + return errs + def _validate_with_caea(self, client, ticket): + """ + A helper method to validate the Receipt made with CAEA + """ + response = client.service.FECAEARegInformativo( + serializers.serialize_ticket(ticket), + serializers.serialize_multiple_receipts_caea(self), + ) + check_response(response) + errs = [] + for cae_data in response.FeDetResp.FECAEADetResponse: + if cae_data.Resultado == ReceiptValidation.RESULT_APPROVED: + validation = ReceiptValidation.objects.create( + result=cae_data.Resultado, + cae=cae_data.CAEA, + # cae_expiration=parsers.parse_date(self.caea.expires), + receipt=self.get( + receipt_number=cae_data.CbteDesde, + ), + processed_date=parsers.parse_datetime( + response.FeCabResp.FchProceso, + ), + caea=True, + ) + if cae_data.Observaciones: + for obs in cae_data.Observaciones.Obs: + observation = Observation.objects.create( + code=obs.Code, + message=obs.Msg, + ) + validation.observations.add(observation) + elif cae_data.Observaciones: + for obs in cae_data.Observaciones.Obs: + errs.append( + "Error {}: {}".format( + obs.Code, + parsers.parse_string(obs.Msg), + ) + ) + return errs + + def _validate(self, ticket=None) -> list[str]: + first = self.first() + assert first is not None # should never happen; mostly a hint for mypy + ticket = ticket or first.point_of_sales.owner.get_or_create_ticket("wsfe") + client = clients.get_client("wsfe", first.point_of_sales.owner.is_sandboxed) + + errs = [] + if "CAEA" not in first.point_of_sales.issuance_type: + errs = self._validate_with_cae(ticket=ticket, client=client) + else: + errs = self._validate_with_caea(ticket=ticket, client=client) return errs @@ -1171,6 +1461,21 @@ class Receipt(models.Model): blank=True, ) + generated = models.DateTimeField( + _("Time when the receipt was created"), + auto_now_add=True, + null=True, + ) + + caea = models.ForeignKey( + Caea, + related_name="receipts", + on_delete=models.PROTECT, + help_text=_("CAEA in case that the receipt must contain it"), + blank=True, + null=True, + ) + objects = ReceiptManager() # TODO: Not implemented: optionals @@ -1221,6 +1526,20 @@ def is_validated(self) -> bool: except ReceiptValidation.DoesNotExist: return False + @property + def ready_to_print(self) -> bool: + """Whether this receipt is ready to print (or a PDF can be generated). + + Will return ``False`` is some validation is required before this instance can be printed. + """ + + if "CAEA" in self.point_of_sales.issuance_type: + return True + + if "CAE" in self.point_of_sales.issuance_type: + return self.is_validated + raise AssertionError("unreachable") + def validate(self, ticket: AuthTicket = None, raise_=False) -> list[str]: """Validates this receipt. @@ -1279,10 +1598,15 @@ def revalidate(self) -> ReceiptValidation | None: return None if receipt_data.Resultado == ReceiptValidation.RESULT_APPROVED: + if receipt_data.EmisionTipo == "CAEA": + cae_expiration = None + else: + cae_expiration = receipt_data.FchVto + validation = ReceiptValidation.objects.create( result=receipt_data.Resultado, cae=receipt_data.CodAutorizacion, - cae_expiration=parsers.parse_date(receipt_data.FchVto), + cae_expiration=parsers.parse_date(cae_expiration), receipt=self, processed_date=parsers.parse_datetime( receipt_data.FchProceso, @@ -1313,7 +1637,12 @@ def __str__(self) -> str: return _("Unnumbered %s") % self.receipt_type class Meta: - ordering = ("issued_date",) + ordering = ( + "issued_date", + "point_of_sales_id", + "receipt_number", + "pk", + ) # this ordering return the same values for first(),last() when filter on 1 day verbose_name = _("receipt") verbose_name_plural = _("receipts") unique_together = [["point_of_sales", "receipt_type", "receipt_number"]] @@ -1457,9 +1786,10 @@ def save_pdf(self, save_model: bool = True) -> None: from django_afip.views import ReceiptPDFView if not self.receipt.is_validated: - raise exceptions.DjangoAfipException( - _("Cannot generate pdf for non-authorized receipt") - ) + if "CAEA" not in self.receipt.point_of_sales.issuance_type: + raise exceptions.DjangoAfipException( + _("Cannot generate pdf for non-authorized receipt") + ) self.pdf_file = File(BytesIO(), name=f"{uuid4().hex}.pdf") render_pdf( @@ -1672,6 +2002,8 @@ class ReceiptValidation(models.Model): cae_expiration = models.DateField( _("cae expiration"), help_text=_("The CAE expiration as returned by the AFIP."), + blank=True, # Must be blank or null when was approved from CAEA operations + null=True, ) observations = models.ManyToManyField( Observation, @@ -1691,6 +2023,14 @@ class ReceiptValidation(models.Model): on_delete=models.PROTECT, ) + caea = models.BooleanField( + default=False, + help_text=_( + "Indicate if the validation was from a CAEA receipt, in that case the field CAE contains the CAEA number" + ), + verbose_name=_("is_caea"), + ) + def __str__(self) -> str: return _("Validation for %s. Result: %s") % ( self.receipt, @@ -1708,3 +2048,29 @@ def __repr__(self) -> str: class Meta: verbose_name = _("receipt validation") verbose_name_plural = _("receipt validations") + + +class InformedCaeas(models.Model): + + pos = models.ForeignKey( + PointOfSales, related_name="informed", on_delete=models.PROTECT + ) + + caea = models.ForeignKey(Caea, related_name="informed", on_delete=models.PROTECT) + + processed_date = models.DateField( + _("processed date"), + ) + + def __str__(self): + return "POS:{}, with CAEA:{}, informed as without movement in {}".format( + self.pos, self.caea, self.processed_date + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["pos", "caea"], + name="unique_migration_pos_caea_combination", + ) + ] diff --git a/django_afip/pdf.py b/django_afip/pdf.py index e1b0d6f9..1884d6b4 100644 --- a/django_afip/pdf.py +++ b/django_afip/pdf.py @@ -26,6 +26,12 @@ def __init__(self, receipt: Receipt): # # Using floats seems to be the only viable solution, and SHOULD be fine for # values in the range supported. + + if "CAEA" in receipt.point_of_sales.issuance_type: + authorization_code = receipt.caea.caea_code + else: + authorization_code = receipt.validation.cae + self._data = { "ver": 1, "fecha": receipt.issued_date.strftime("%Y-%m-%d"), @@ -38,8 +44,8 @@ def __init__(self, receipt: Receipt): "ctz": float(receipt.currency_quote), "tipoDocRec": receipt.document_type.code, "nroDocRec": receipt.document_number, - "tipoCodAut": "E", # We don't support anything except CAE. - "codAut": receipt.validation.cae, + "tipoCodAut": "E", + "codAut": authorization_code, } def as_png(self) -> Image: diff --git a/django_afip/serializers.py b/django_afip/serializers.py index ee1250a9..dfbc96a1 100644 --- a/django_afip/serializers.py +++ b/django_afip/serializers.py @@ -1,7 +1,11 @@ +from datetime import datetime + from django.utils.functional import LazyObject from django_afip.clients import get_client +from .exceptions import CaeaCountError + class _LazyFactory(LazyObject): """A lazy-initialised factory for WSDL objects.""" @@ -25,6 +29,13 @@ def serialize_datetime(datetime): return datetime.strftime("%Y-%m-%dT%H:%M:%S-00:00") +def serialize_datetime_caea(datetime): + """ + A similar serealizer to the above one but, use a diferent format. + """ + return datetime.strftime("%Y%m%d%H%M%S") + + def serialize_date(date): return date.strftime("%Y%m%d") @@ -37,7 +48,78 @@ def serialize_ticket(ticket): ) +def serialize_multiple_receipts_caea(receipts): + + receipts = receipts.all().order_by("receipt_number") + + first = receipts.first() + receipts = [serialize_receipt_caea(receipt) for receipt in receipts] + + serialised = f.FECAEARequest( + FeCabReq=f.FECAEACabRequest( + CantReg=len(receipts), + PtoVta=first.point_of_sales.number, + CbteTipo=first.receipt_type.code, + ), + FeDetReq=f.ArrayOfFECAEADetRequest(receipts), + ) + + return serialised + + +def serialize_receipt_caea(receipt): + taxes = receipt.taxes.all() + vats = receipt.vat.all() + + serialized = f.FECAEADetRequest( + Concepto=receipt.concept.code, + DocTipo=receipt.document_type.code, + DocNro=receipt.document_number, + # TODO: Check that this is not None!, + CbteDesde=receipt.receipt_number, + CbteHasta=receipt.receipt_number, + CbteFch=serialize_date(receipt.issued_date), + ImpTotal=receipt.total_amount, + ImpTotConc=receipt.net_untaxed, + ImpNeto=receipt.net_taxed, + ImpOpEx=receipt.exempt_amount, + ImpIVA=sum(vat.amount for vat in vats), + ImpTrib=sum(tax.amount for tax in taxes), + MonId=receipt.currency.code, + MonCotiz=receipt.currency_quote, + ) + if int(receipt.concept.code) in (2, 3): + serialized.FchServDesde = serialize_date(receipt.service_start) + serialized.FchServHasta = serialize_date(receipt.service_end) + serialized.FchVtoPago = serialize_date(receipt.expiration_date) + + if taxes: + serialized.Tributos = f.ArrayOfTributo([serialize_tax(tax) for tax in taxes]) + + if vats: + serialized.Iva = f.ArrayOfAlicIva([serialize_vat(vat) for vat in vats]) + + related_receipts = receipt.related_receipts.all() + if related_receipts: + serialized.CbtesAsoc = f.ArrayOfCbteAsoc( + [ + f.CbteAsoc( + r.receipt_type.code, + r.point_of_sales.number, + r.receipt_number, + ) + for r in related_receipts + ] + ) + + serialized.CAEA = receipt.caea.caea_code + serialized.CbteFchHsGen = serialize_datetime_caea(receipt.generated) + + return serialized + + def serialize_multiple_receipts(receipts): + receipts = receipts.all().order_by("receipt_number") first = receipts.first() @@ -125,3 +207,18 @@ def serialize_receipt_data(receipt_type, receipt_number, point_of_sales): return f.FECompConsultaReq( CbteTipo=receipt_type, CbteNro=receipt_number, PtoVta=point_of_sales ) + + +def serialize_caea_period(period: int = None): + if period: + return period + else: + date = datetime.now() + return date.strftime("%Y%m") + + +def serialize_caea_order(order: int = None) -> int: + if order: + return order + else: + return 1 diff --git a/django_afip/signals.py b/django_afip/signals.py index e6c11eb9..fb05b23b 100644 --- a/django_afip/signals.py +++ b/django_afip/signals.py @@ -1,7 +1,10 @@ +import datetime + from django.db.models.signals import post_save from django.db.models.signals import pre_save from django.dispatch import receiver +from django_afip import exceptions from django_afip import models @@ -13,5 +16,47 @@ def update_certificate_expiration(sender, instance: models.TaxPayer, **kwargs): @receiver(post_save, sender=models.ReceiptPDF) def generate_receipt_pdf(sender, instance: models.ReceiptPDF, **kwargs): - if not instance.pdf_file and instance.receipt.is_validated: - instance.save_pdf(save_model=True) + if not instance.pdf_file: + if instance.receipt.ready_to_print: + instance.save_pdf(save_model=True) + + +@receiver(pre_save, sender=models.Receipt) +def save_caea_data(sender, instance: models.TaxPayer, **kwargs): + if "CAEA" not in instance.point_of_sales.issuance_type: + return + + if instance.caea == "" or instance.caea == None: + date = datetime.datetime.now() + period = date.today().strftime("%Y%m") + order = 1 + if date.day > 15: + order = 2 + caea = models.Caea.objects.active().filter( + taxpayer=instance.point_of_sales.owner, + period=int(period), + order=order, + ) + if caea.count() != 1: + raise exceptions.CaeaCountError + else: + if instance.caea == None or instance.caea == "": + instance.caea = caea[0] + + if instance.receipt_number == None or instance.receipt_number == "": + + last_number = ( + models.Receipt.objects.filter( + point_of_sales=instance.point_of_sales, + receipt_type=instance.receipt_type, + ) + .order_by("-receipt_number") + .values_list("receipt_number", flat=True) + .first() + ) + + if last_number == None: # First record on the db + last_number = 0 + + correct_number = last_number + 1 + instance.receipt_number = correct_number diff --git a/django_afip/templates/receipts/code_6.html b/django_afip/templates/receipts/code_6.html index 176e61cb..123ff386 100644 --- a/django_afip/templates/receipts/code_6.html +++ b/django_afip/templates/receipts/code_6.html @@ -116,10 +116,17 @@

- CAE - {{ pdf.receipt.validation.cae }} - Vto CAE - {{ pdf.receipt.validation.cae_expiration }} + {% if pdf.receipt.caea %} + CAEA + {{ pdf.receipt.caea.caea_code }} + Vto CAEA + {{ pdf.receipt.caea.expires }} + {% else %} + CAE + {{ pdf.receipt.validation.cae }} + Vto CAE + {{ pdf.receipt.validation.cae_expiration }} + {%endif%}

Consultas de validez: diff --git a/testapp/testapp/settings.py b/testapp/testapp/settings.py index 8aeddaa1..fc7de679 100644 --- a/testapp/testapp/settings.py +++ b/testapp/testapp/settings.py @@ -63,6 +63,12 @@ WSGI_APPLICATION = "testapp.wsgi.application" # Database +# DATABASES = { +# "default": { +# "ENGINE": "django.db.backends.sqlite3", +# "NAME": BASE_DIR / "db.sqlite3", +# } +# } DATABASES = {"default": dj_database_url.config()} # Internationalization diff --git a/tests/conftest.py b/tests/conftest.py index 7b34735b..7446a1c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +from datetime import datetime from unittest.mock import patch import pytest @@ -102,3 +103,11 @@ def populated_db(live_ticket, live_taxpayer): models.load_metadata() live_taxpayer.fetch_points_of_sales() + + period = datetime.now().strftime("%Y%m") + if datetime.today().day > 15: + order = 2 + else: + order = 1 + + live_taxpayer.fetch_or_create_caea(period=period, order=order) diff --git a/tests/test_models.py b/tests/test_models.py index d24869b0..239365bd 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,18 +1,24 @@ +from datetime import datetime from unittest.mock import MagicMock from unittest.mock import call from unittest.mock import patch +import django import pytest +from django.utils.timezone import make_aware from pytest_django.asserts import assertQuerysetEqual from django_afip import exceptions from django_afip import factories from django_afip import models +from django_afip.factories import CaeaFactory from django_afip.factories import ReceiptFactory from django_afip.factories import ReceiptValidationFactory from django_afip.factories import ReceiptWithApprovedValidation from django_afip.factories import ReceiptWithInconsistentVatAndTaxFactory from django_afip.factories import ReceiptWithVatAndTaxFactory +from django_afip.factories import SubFactory +from django_afip.factories import TaxPayerFactory def test_default_receipt_queryset(): @@ -142,7 +148,6 @@ def test_fetch_existing_data(populated_db): receipt_number=last_receipt_number, point_of_sales=pos, ) - assert receipt.CbteDesde == last_receipt_number assert receipt.PtoVta == pos.number @@ -329,6 +334,210 @@ def test_populate_method(live_ticket): assert models.CurrencyType.objects.count() == 50 +@pytest.mark.django_db +def test_caea_creation(): + caea = factories.CaeaFactory() + + assert len(str(caea.caea_code)) == 14 + assert str(caea.caea_code) == "12345678974125" + + +@pytest.mark.django_db +def test_caea_creation_should_fail(): + + with pytest.raises( + ValueError, + match="Field 'caea_code' expected a number but got 'A234567897412B'.", + ): + caea = factories.CaeaFactory(caea_code="A234567897412B") + + +@pytest.mark.django_db +@pytest.mark.live +def test_caea_creation_live(populated_db): + + caea = models.Caea.objects.first() + + assert len(str(caea.caea_code)) == 14 + assert str(caea.period) == datetime.today().strftime("%Y%m") + + +@pytest.mark.django_db +def test_create_receipt_caea(): + + pos = factories.PointOfSalesFactoryCaea() + caea = factories.CaeaFactory() + # caea = models.Caea.objects.get(pk=1) + pos_caea = models.PointOfSales.objects.all().filter(issuance_type="CAEA") + receipt = factories.ReceiptFactory(point_of_sales=pos) + + assert len(pos_caea) == 1 + assert receipt.point_of_sales.issuance_type == "CAEA" + assert str(receipt.caea.caea_code) == caea.caea_code + assert receipt.receipt_number == 1 + + +@pytest.mark.django_db +def test_two_caea_unique_constraint_should_fail(): + """ + Test that 2 caea with same order,period and taxpayer cannot be created + """ + + pos = factories.PointOfSalesFactoryCaea() + caea = factories.CaeaFactory() + with pytest.raises( + django.db.utils.IntegrityError, + ): + caea2 = factories.CaeaFactory(caea_code="12345678912346") + + +@pytest.mark.django_db +def test_caea_reverse_relation_receipts(): + + pos = factories.PointOfSalesFactoryCaea() + caea = factories.CaeaFactory() + # caea = models.Caea.objects.get(pk=1) + receipt_1 = factories.ReceiptFactory(point_of_sales=pos) + receipt_2 = factories.ReceiptFactory(point_of_sales=pos) + assert receipt_1 != receipt_2 + + receipts_with_caea = caea.receipts.all() + assert receipt_1 and receipt_2 in receipts_with_caea + assert len(receipts_with_caea) == 2 + assert receipt_1.receipt_number == 1 + assert receipt_2.receipt_number == 2 + + +@pytest.mark.django_db +@pytest.mark.live +def test_validate_caea_receipt(populated_db): + + manager = models.ReceiptManager() + receipt_type = models.ReceiptType.objects.get(code=6) + pos = factories.PointOfSalesFactoryCaea() + # caea = factories.CaeaFactory() + caea = models.Caea.objects.get(pk=1) + last_number = manager.fetch_last_receipt_number( + point_of_sales=pos, receipt_type=receipt_type + ) + + receipt_1 = factories.ReceiptWithVatAndTaxFactoryCaea(point_of_sales=pos) + receipt_1.receipt_number = last_number + 1 + receipt_1.save() + + errs = receipt_1.validate() + + assert len(errs) == 0 + assert receipt_1.validation.result == models.ReceiptValidation.RESULT_APPROVED + assert models.ReceiptValidation.objects.count() == 1 + + +@pytest.mark.django_db +@pytest.mark.live +def test_validate_caea_receipt_another_pos(populated_db): + + manager = models.ReceiptManager() + receipt_type = models.ReceiptType.objects.get(code=6) + pos = factories.PointOfSalesFactoryCaea() + # caea = factories.CaeaFactory() + caea = models.Caea.objects.get(pk=1) + last_number = manager.fetch_last_receipt_number( + point_of_sales=pos, receipt_type=receipt_type + ) + + receipt_1 = factories.ReceiptWithVatAndTaxFactoryCaea(point_of_sales=pos) + receipt_1.receipt_number = last_number + 1 + receipt_1.save() + receipt_2 = factories.ReceiptWithVatAndTaxFactoryCaea(point_of_sales=pos) + receipt_2.receipt_number = last_number + 2 + receipt_2.save() + + assert receipt_1 != receipt_2 + assert last_number + 1 == receipt_1.receipt_number + assert last_number + 2 == receipt_2.receipt_number + + qs = models.Receipt.objects.filter(point_of_sales=pos).filter( + validation__isnull=True + ) + errs = qs.validate() + + assert len(errs) == 0 + assert receipt_1.validation.result == models.ReceiptValidation.RESULT_APPROVED + assert receipt_2.validation.result == models.ReceiptValidation.RESULT_APPROVED + assert models.ReceiptValidation.objects.count() == 2 + + +@pytest.mark.django_db +@pytest.mark.live +def test_validate_credit_note_caea(populated_db): + + """Test validating a receipt with credit note attached.""" + # fetch data from afip to set the receipt number + manager = models.ReceiptManager() + receipt_type_fact = models.ReceiptType.objects.get(code=6) + receipt_type_cn = models.ReceiptType.objects.get(code=8) + pos = factories.PointOfSalesFactoryCaea() + # caea = factories.CaeaFactory() + caea = models.Caea.objects.get(pk=1) + last_number_fact = manager.fetch_last_receipt_number( + point_of_sales=pos, receipt_type=receipt_type_fact + ) + + last_number_cn = manager.fetch_last_receipt_number( + point_of_sales=pos, receipt_type=receipt_type_cn + ) + + # Create a receipt (this credit note relates to it): + receipt = factories.ReceiptWithVatAndTaxFactoryCaea(point_of_sales=pos) + receipt.receipt_number = last_number_fact + 1 + receipt.save() + + errs = receipt.validate() + assert len(errs) == 0 + + # Create a credit note for the above receipt: + credit_note = ReceiptWithVatAndTaxFactory( + receipt_type__code=8, point_of_sales=pos + ) # Nota de Crédito B + credit_note.related_receipts.add(receipt) + credit_note.receipt_number = last_number_cn + 1 + credit_note.save() + + credit_note.validate(raise_=True) + assert credit_note.receipt_number == (last_number_cn + 1) + assert credit_note.validation.result == models.ReceiptValidation.RESULT_APPROVED + + +@pytest.mark.django_db +@pytest.mark.live +def test_inform_caea_without_movement(populated_db): + pos = factories.PointOfSalesFactoryCaea() + caea = models.Caea.objects.get(pk=1) + payer = factories.TaxPayerFactory() + + resp = caea.consult_caea_without_operations(pos=pos) + assert isinstance(resp, models.InformedCaeas) + + +@pytest.mark.django_db +@pytest.mark.live +def test_creation_informedcaea(populated_db): + pos = factories.PointOfSalesFactoryCaea() + caea = models.Caea.objects.get(pk=1) + payer = factories.TaxPayerFactory() + + with pytest.raises( + models.InformedCaeas.DoesNotExist, + ): + informed_caea = models.InformedCaeas.objects.get(pos=pos, caea=caea) + + caea.consult_caea_without_operations(pos=pos) + informed_caea = models.InformedCaeas.objects.get(pos=pos, caea=caea) + assert informed_caea.pk == 1 + assert informed_caea.caea == caea + assert informed_caea.pos == pos + + @pytest.mark.django_db def test_receipt_entry_without_discount(): """ @@ -384,3 +593,161 @@ def test_receipt_entry_gt_total_discount(): with pytest.raises(Exception, match=r"\bdiscount_less_than_total\b"): factories.ReceiptEntryFactory(quantity=1, unit_price=1, discount=2) + + +@pytest.mark.django_db +def test_bad_retrive_caea(): + """ + Test that in the way that the CAEA is assigned in the save signal even if + there are multiple taxpayer with 1 CAEA each the code assigned well. + + It not ensure that if the same taxpayer has multiples actives CAEAs the correct will be assigned + + """ + caea_good = CaeaFactory() + caea_bad = CaeaFactory( + caea_code="12345678974188", + valid_since=make_aware(datetime(2022, 5, 15)), + expires=make_aware(datetime(2022, 5, 30)), + generated=make_aware(datetime(2022, 5, 30, 21, 6, 4)), + taxpayer=TaxPayerFactory( + name="Jane Doe", + cuit=20366642330, + ), + ) + + pos = factories.PointOfSalesFactoryCaea() + receipt = factories.ReceiptFactory(point_of_sales=pos) + + assert caea_bad.caea_code != receipt.caea.caea_code + + +@pytest.mark.django_db +def test_caea_assigned_receipt_correct(): + """ + Test that even if a taxpayer has multiples actives CAEAs the correct will be assigned + """ + caea_good = CaeaFactory() + caea_bad = CaeaFactory( + caea_code="12345678974188", + period=202209, + order="1", + valid_since=make_aware(datetime(2022, 4, 15)), + expires=make_aware(datetime(2022, 4, 30)), + generated=make_aware(datetime(2022, 4, 14, 21, 6, 4)), + ) + + assert caea_bad != caea_good + + pos = factories.PointOfSalesFactoryCaea() + receipt = factories.ReceiptFactory(point_of_sales=pos) + + assert caea_bad.caea_code != receipt.caea.caea_code + assert ( + models.Caea.objects.all().filter(taxpayer=receipt.point_of_sales.owner).count() + == 2 + ) + + +@pytest.mark.django_db +def test_ordering_receipts_work(): + + receipt_1 = ReceiptFactory() + receipt_2 = ReceiptFactory() + receipt_3 = ReceiptFactory() + + assert models.Receipt.objects.last() == receipt_3 + + +@pytest.mark.django_db +def test_isactive_caea(): + + caea = CaeaFactory( + period=datetime.today().strftime("%Y%m"), + order=1, + valid_since=datetime(2022, 10, 1), + expires=datetime(2022, 10, 15), + ) + + caea_2 = CaeaFactory( + caea_code="12345678974128", + period=datetime.today().strftime("%Y%m"), + order=2, + valid_since=datetime(2022, 10, 16), + expires=datetime(2022, 10, 31), + ) + + assert caea.valid_since <= datetime(2022, 10, 1) # Should be true + assert caea.expires <= datetime(2022, 10, 15) # Should be true + + assert caea.valid_since <= datetime(2022, 10, 15) <= caea.expires # Should be true + assert caea.valid_since <= datetime(2022, 10, 1) <= caea.expires # Should be true + + assert caea.valid_since > datetime(2022, 9, 30) # Should be true + assert caea.expires < datetime(2022, 10, 16) # Should be true + + assert not caea.is_active + assert caea_2.is_active + + +@pytest.mark.django_db +def test_caea_queryset(): + + caea = CaeaFactory( + valid_since=datetime(2022, 10, 16), + expires=datetime(2022, 10, 31), + ) + + caea_2 = CaeaFactory( + caea_code="12345678974128", + period=datetime.today().strftime("%Y%m"), + order=1, + valid_since=datetime(2022, 8, 16), + expires=datetime(2022, 8, 31), + ) + caea_active = models.Caea.objects.first() + caea_active_count = models.Caea.objects.active().count() + + assert not caea_2.is_active + assert caea.is_active + assert caea == caea_active + assert caea_active_count == 1 + + +@pytest.mark.django_db +def test_receipt_caea_get_correct_numeration(): + + pos = factories.PointOfSalesFactoryCaea() + caea = factories.CaeaFactory() + # caea = models.Caea.objects.get(pk=1) + receipt_1 = factories.ReceiptFactory(point_of_sales=pos) + receipt_1.receipt_number = 28930 + receipt_1.save() + receipt_2 = factories.ReceiptFactory(point_of_sales=pos) + + assert receipt_1.receipt_number == 28930 + assert receipt_2.receipt_number == 28931 + + +@pytest.mark.django_db +def test_mixed_receipts_caea_get_correct_numeration(): + + pos = factories.PointOfSalesFactoryCaea() + caea = factories.CaeaFactory() + # caea = models.Caea.objects.get(pk=1) + receipt_1 = factories.ReceiptFactory(point_of_sales=pos) + receipt_2 = factories.ReceiptFactory(point_of_sales=pos) + + # factura A + receipt_type = factories.ReceiptTypeFactory(code=1) + receipt_a_1 = factories.ReceiptFactory( + receipt_type=receipt_type, point_of_sales=pos + ) + receipt_a_2 = factories.ReceiptFactory( + receipt_type=receipt_type, point_of_sales=pos + ) + + assert receipt_1.receipt_number == 1 + assert receipt_2.receipt_number == 2 + assert receipt_a_1.receipt_number == 1 + assert receipt_a_2.receipt_number == 2 diff --git a/tests/test_pdf.py b/tests/test_pdf.py index 3cb2bf74..f7bdc381 100644 --- a/tests/test_pdf.py +++ b/tests/test_pdf.py @@ -91,3 +91,81 @@ def test_qrcode_data(): "tipoDocRec": 96, "ver": 1, } + + +# -------------------------------------------- +@pytest.mark.django_db +def test_pdf_generation_caea(): + """Test PDF file generation. + + For the moment, this test case mostly verifies that pdf generation + *works*, but does not actually validate the pdf file itself. + + Running this locally *will* yield the file itself, which is useful for + manual inspection. + """ + caea = factories.CaeaFactory() + pdf = factories.ReceiptPDFFactory( + receipt=factories.ReceiptFactory( + point_of_sales=factories.SubFactory(factories.PointOfSalesFactoryCaea) + ), + receipt__receipt_number=3, + ) + factories.ReceiptValidationFactory(receipt=pdf.receipt) + pdf.save_pdf() + regex = r"afip/receipts/[a-f0-9]{2}/[a-f0-9]{2}/[a-f0-9]{32}.pdf" + + assert re.match(regex, pdf.pdf_file.name) + assert pdf.pdf_file.name.endswith(".pdf") + + +@pytest.mark.django_db +def test_qrcode_data_caea(): + caea = factories.CaeaFactory() + pos_caea = factories.PointOfSalesFactoryCaea() + receipt = factories.ReceiptFactory(point_of_sales=pos_caea) + pdf = factories.ReceiptPDFFactory( + receipt=receipt, + ) + factories.ReceiptValidationFactory(receipt=pdf.receipt, cae=caea.caea_code) + + qrcode = ReceiptQrCode(pdf.receipt) + assert qrcode._data == { + "codAut": int(caea.caea_code), + "ctz": 1.0, + "cuit": "20329642330", + "fecha": str(date.today()), + "importe": 130.0, + "moneda": "PES", + "nroCmp": receipt.receipt_number, + "nroDocRec": receipt.document_number, + "ptoVta": receipt.point_of_sales.number, + "tipoCmp": receipt.receipt_type.code, + "tipoCodAut": "E", + "tipoDocRec": receipt.document_type.code, + "ver": 1, + } + + +@pytest.mark.django_db +def test_signal_generation_for_not_validated_receipt(): + caea = factories.CaeaFactory() + pos = factories.PointOfSalesFactoryCaea() + receipt = factories.ReceiptFactory(point_of_sales=pos) + printable = factories.ReceiptPDFFactory(receipt=receipt) + assert ( + printable.pdf_file + ) # even if is not validated with CAEA must print make a PDF + assert not receipt.is_validated + + +@pytest.mark.django_db +def test_signal_generation_for_not_validated_receipt(): + caea = factories.CaeaFactory() + pos = factories.PointOfSalesFactoryCaea() + receipt = factories.ReceiptFactory(point_of_sales=pos) + validation = factories.ReceiptValidationFactory(receipt=receipt) + + printable = factories.ReceiptPDFFactory(receipt=receipt) + assert printable.pdf_file + assert receipt.is_validated