diff --git a/django_paypal/__init__.py b/django_paypal/__init__.py index 0404d81..1f356cc 100644 --- a/django_paypal/__init__.py +++ b/django_paypal/__init__.py @@ -1 +1 @@ -__version__ = '0.3.0' +__version__ = '1.0.0' diff --git a/django_paypal/admin.py b/django_paypal/admin.py index dd62de2..f68e6ef 100644 --- a/django_paypal/admin.py +++ b/django_paypal/admin.py @@ -1,21 +1,17 @@ from django.contrib import admin -from .models import PaypalPayment, PaypalTransaction, PaypalItem +from .models import PaypalOrder, PaypalAPIResponse -class PaypalPaymentAdmin(admin.ModelAdmin): +class PaypalOrderAdmin(admin.ModelAdmin): pass -admin.site.register(PaypalPayment, PaypalPaymentAdmin) +admin.site.register(PaypalOrder, PaypalOrderAdmin) -class PaypalTransactionAdmin(admin.ModelAdmin): - pass - -admin.site.register(PaypalTransaction, PaypalTransactionAdmin) - -class PaypalItemAdmin(admin.ModelAdmin): +class PaypalAPIResponseAdmin(admin.ModelAdmin): pass -admin.site.register(PaypalItem, PaypalItemAdmin) + +admin.site.register(PaypalAPIResponse, PaypalAPIResponseAdmin) diff --git a/django_paypal/api_types.py b/django_paypal/api_types.py new file mode 100644 index 0000000..22009d1 --- /dev/null +++ b/django_paypal/api_types.py @@ -0,0 +1,424 @@ +from dataclasses import dataclass +from typing import Optional, List, Literal, NamedTuple, Dict, Any +from dataclass_wizard import JSONWizard + +ItemCategories = Literal['DIGITAL_GOODS', 'PHYSICAL_GOODS', 'DONATION'] +ShippingType = Literal['SHIPPING', 'PICKUP_IN_PERSON', 'PICKUP_IN_STORE', 'PICKUP_FROM_PERSON'] +CardType = Literal[ + 'VISA', + 'MASTERCARD', + 'DISCOVER', + 'AMEX', + 'SOLO', + 'JCB', + 'STAR', + 'DELTA', + 'SWITCH', + 'MAESTRO', + 'CB_NATIONALE', + 'CONFIGOGA', + 'CONFIDIS', + 'ELECTRON', + 'CETELEM', + 'CHINA_UNION_PAY', +] + + +@dataclass +class OAuthResponse: + scope: str + access_token: str + token_type: str + app_id: str + expires_in: int + nonce: str + + +@dataclass +class PhoneNumber: + national_number: str + + +@dataclass +class Phone: + phone_number: PhoneNumber + phone_type: Optional[Literal['FAX', 'HOME', 'MOBILE', 'OTHER', 'PAGER']] = None + + +@dataclass +class AccountHolder: + given_name: Optional[str] = None + surname: Optional[str] = None + full_name: Optional[str] = None + + def __post_init__(self): + if self.given_name and len(self.given_name) > 140: + raise ValueError('Given name must be 140 characters or fewer') + if self.surname and len(self.surname) > 140: + raise ValueError('Surname must be 140 characters or fewer') + + +@dataclass +class ExperienceContext: + brand_name: Optional[str] = None + shipping_preference: Optional[Literal['GET_FROM_FILE', 'NO_SHIPPING', 'SET_PROVIDED_ADDRESS']] = None + landing_page: Optional[Literal['LOGIN', 'GUEST_CHECKOUT', 'NO_PREFERENCE']] = None + user_action: Optional[Literal['CONTINUE', 'PAY_NOW']] = None + payment_method_preference: Optional[Literal['UNRESTRICTED', 'IMMEDIATE_PAYMENT_REQUIRED']] = None + locale: Optional[str] = None + return_url: Optional[str] = None + cancel_url: Optional[str] = None + + +@dataclass +class TaxInfo: + tax_id: Optional[str] = None + tax_id_type: Optional[str] = None + + +@dataclass +class Address: + country_code: str + address_line_1: Optional[str] = None + address_line_2: Optional[str] = None + admin_area_1: Optional[str] = None + admin_area_2: Optional[str] = None + postal_code: Optional[str] = None + + +@dataclass +class Customer: + id: Optional[str] = None + email_address: Optional[str] = None + phone: Optional[Phone] = None + + +@dataclass +class Vault: + usage_type: str + store_in_vault: Optional[str] = None + description: Optional[str] = None + owner_id: Optional[str] = None + customer_type: Optional[str] = None + permit_multiple_payment_tokens: Optional[bool] = None + + +@dataclass +class Attributes: + customer: Optional[Customer] = None + vault: Optional[Vault] = None + + +@dataclass +class PayPal: + experience_context: Optional[ExperienceContext] = None + billing_agreement_id: Optional[str] = None + vault_id: Optional[str] = None + email_address: Optional[str] = None + birth_date: Optional[str] = None + tax_info: Optional[TaxInfo] = None + address: Optional[Address] = None + attributes: Optional[Attributes] = None + + +@dataclass +class PaymentSource(JSONWizard): + class _(JSONWizard.Meta): + key_transform_with_dump = 'SNAKE' + + paypal: PayPal + + +@dataclass +class Amount: + currency_code: str + value: str + + +@dataclass +class Tax: + currency_code: str + value: str + + +@dataclass +class PurchaseItem: + name: str + quantity: int + category: ItemCategories + unit_amount: Amount + description: Optional[str] = None + sku: Optional[str] = None + tax: Optional[Tax] = None + + +@dataclass +class Breakdown: + item_total: Optional[Amount] = None + shipping: Optional[Amount] = None + handling: Optional[Amount] = None + tax_total: Optional[Amount] = None + shipping_discount: Optional[Amount] = None + discount: Optional[Amount] = None + + +@dataclass +class PurchaseUnitAmount(Amount): + breakdown: Optional[Breakdown] = None + + +@dataclass +class Payee: + email_address: Optional[str] = None + merchant_id: Optional[str] = None + + +@dataclass +class PlatformFees: + amount: Optional[Amount] + payee_pricing_tier_id: Optional[str] = None + payee_receivable_fx_rate_id: Optional[str] = None + disbursement_mode: Optional[Literal['INSTANT', 'DELAYED']] = None + + +@dataclass +class PaymentInstruction: + platform_fees: Optional[PlatformFees] = None + + +@dataclass +class ShippingOption: + id: str + label: str + selected: bool + type: ShippingType + amount: Optional[Amount] = None + + +@dataclass +class Shipping: + type: ShippingType = None + options: Optional[List[ShippingOption]] = None + name: Optional[AccountHolder] = None + address: Optional[Address] = None + + +@dataclass +class Level2Data: + invoice_id: Optional[str] = None + tax_total: Optional[Amount] = None + + +@dataclass +class LineItem: + name: str + quantity: int + unit_amount: Amount + description: Optional[str] = None + sku: Optional[str] = None + category: Optional[ItemCategories] = None + tax: Optional[Amount] = None + commodity_code: Optional[str] = None + unit_of_measure: Optional[str] = None + discount_amount: Optional[Amount] = None + total_amount: Optional[Amount] = None + + +@dataclass +class Level3Data: + ships_from_postal_code: Optional[str] = None + line_items: Optional[List[LineItem]] = None + shipping_amount: Optional[Amount] = None + duty_amount: Optional[Amount] = None + discount_amount: Optional[Amount] = None + address: Optional[Address] = None + + +@dataclass +class SupplementaryCardData: + level_2: Optional[Level2Data] = None + level_3: Optional[Level3Data] = None + + +@dataclass +class SupplementaryData: + card: SupplementaryCardData + + +@dataclass +class PurchaseUnit(JSONWizard): + class _(JSONWizard.Meta): + key_transform_with_dump = 'SNAKE' + + amount: PurchaseUnitAmount + reference_id: Optional[str] = None + description: Optional[str] = None + custom_id: Optional[str] = None + invoice_id: Optional[str] = None + soft_descriptor: Optional[str] = None + items: Optional[List[PurchaseItem]] = None + payee: Optional[Payee] = None + payment_instruction: Optional[PaymentInstruction] = None + shipping: Optional[Shipping] = None + supplementary_data: Optional[SupplementaryData] = None + + +@dataclass +class PreviousNetworkTransactionReference: + id: str + network: CardType + date: Optional[str] = None + acquirer_reference_number: Optional[str] = None + + +@dataclass +class StoredPaymentSource: + payment_initiator: Literal['CUSTOMER', 'MERCHANT'] + payment_type: Literal['ONE_TIME', 'RECURRING', 'UNSCHEDULED'] + usage: Optional[Literal['FIRST', 'SUBSEQUENT', 'DERIVED']] = None + previous_network_transaction_reference: Optional[PreviousNetworkTransactionReference] = None + + +@dataclass +class ApplicationContext(JSONWizard): + class _(JSONWizard.Meta): + key_transform_with_dump = 'SNAKE' + + stored_payment_source: StoredPaymentSource + + +class APIAuthCredentials(NamedTuple): + client_id: str + client_secret: str + + +@dataclass +class Link: + href: str + method: Literal['GET', 'POST', 'PATCH', 'DELETE'] + rel: str + + +@dataclass +class OrderCreatedAPIResponse(JSONWizard): + class _(JSONWizard.Meta): + key_transform_with_dump = 'SNAKE' + + id: str + links: List[Link] + payment_source: PaymentSource + status: str + + +@dataclass +class Payer: + payer_id: str + address: Optional[Address] = None + email_address: Optional[str] = None + name: Optional[AccountHolder] = None + + +@dataclass +class OrderDetailPayPal: + account_id: str + account_status: str + address: Address + email_address: str + name: AccountHolder + + +@dataclass +class OrderDetailPaymentSource: + paypal: OrderDetailPayPal + + +@dataclass +class OrderDetailAPIResponse(JSONWizard): + class _(JSONWizard.Meta): + key_transform_with_dump = 'SNAKE' + + id: str + create_time: str + links: List[Link] + payer: Payer + payment_source: OrderDetailPaymentSource + purchase_units: List[PurchaseUnit] + status: str + + +@dataclass +class SellerProtection: + dispute_categories: List[str] + status: str + + +@dataclass +class SellerReceivableBreakdown: + gross_amount: Amount + net_amount: Amount + paypal_fee: Amount + + +@dataclass +class Capture: + amount: Amount + create_time: str + final_capture: bool + id: str + links: List[Link] + seller_protection: SellerProtection + seller_receivable_breakdown: SellerReceivableBreakdown + status: str + update_time: str + + +@dataclass +class Payments: + captures: List[Capture] + + +@dataclass +class CaptureAddress: + address_line_1: Optional[str] = None + address_line_2: Optional[str] = None + admin_area_1: Optional[str] = None + admin_area_2: Optional[str] = None + postal_code: Optional[str] = None + + +@dataclass +class CaptureShipping: + address: CaptureAddress + name: AccountHolder + + +@dataclass +class CapturedPurchaseUnit: + payments: Payments + reference_id: str + shipping: CaptureShipping + + +@dataclass +class OrderCaptureAPIResponse(JSONWizard): + class _(JSONWizard.Meta): + key_transform_with_dump = 'SNAKE' + + id: str + links: List[Link] + payer: Payer + payment_source: OrderDetailPaymentSource + purchase_units: List[CapturedPurchaseUnit] + status: str + + +class WebhookEvent(NamedTuple): + create_time: str + event_type: str + event_version: str + id: str + links: List[Link] + resource: Dict[str, Any] + resource_type: str + resource_version: str + summary: str diff --git a/django_paypal/exceptions.py b/django_paypal/exceptions.py new file mode 100644 index 0000000..4b0d25f --- /dev/null +++ b/django_paypal/exceptions.py @@ -0,0 +1,13 @@ +from requests import RequestException + + +class PaypalAuthFailure(RequestException): + pass + + +class PaypalAPIError(RequestException): + pass + + +class PaypalWebhookVerificationError(BaseException): + pass diff --git a/django_paypal/migrations/0005_auto_20240206_0746.py b/django_paypal/migrations/0005_auto_20240206_0746.py new file mode 100644 index 0000000..e375955 --- /dev/null +++ b/django_paypal/migrations/0005_auto_20240206_0746.py @@ -0,0 +1,76 @@ +# Generated by Django 2.2.28 on 2024-02-06 06:46 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_paypal', '0004_paypalpayment_related_resource_state'), + ] + + operations = [ + migrations.CreateModel( + name='PaypalOrder', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order_id', models.CharField(max_length=255, unique=True, verbose_name='Order ID')), + ('status', models.CharField(blank=True, max_length=255, verbose_name='Status')), + ], + ), + migrations.CreateModel( + name='PaypalWebhook', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('webhook_id', models.CharField(max_length=255, unique=True, verbose_name='Webhook ID')), + ('url', models.CharField(max_length=255, unique=True, verbose_name='URL')), + ('events', django.contrib.postgres.fields.jsonb.JSONField(verbose_name='Active Webhook Events')), + ], + ), + migrations.RenameModel( + old_name='PaypalItem', + new_name='LegacyPaypalItem', + ), + migrations.RenameModel( + old_name='PaypalPayment', + new_name='LegacyPaypalPayment', + ), + migrations.RenameModel( + old_name='PaypalTransaction', + new_name='LegacyPaypalTransaction', + ), + migrations.AlterModelOptions( + name='legacypaypalitem', + options={'verbose_name': 'Legacy Paypal Item (payments v1 API)', 'verbose_name_plural': 'Legacy Paypal Item (payments v1 API)'}, + ), + migrations.AlterModelOptions( + name='legacypaypalpayment', + options={'verbose_name': 'Legacy Paypal Payment (payments v1 API)', 'verbose_name_plural': 'Legacy Paypal Payments (payments v1 API)'}, + ), + migrations.AlterModelOptions( + name='legacypaypaltransaction', + options={'verbose_name': 'Legacy Paypal Transaction (payments v1 API)', 'verbose_name_plural': 'Legacy Paypal Transactions (payments v1 API)'}, + ), + migrations.CreateModel( + name='PaypalAPIResponse', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.CharField(blank=True, max_length=255, verbose_name='URL')), + ('response_data', django.contrib.postgres.fields.jsonb.JSONField(verbose_name='Response Data')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_responses', to='django_paypal.PaypalOrder')), + ], + ), + migrations.CreateModel( + name='PaypalAPIPostData', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.CharField(blank=True, max_length=255, verbose_name='URL')), + ('post_data', django.contrib.postgres.fields.jsonb.JSONField(verbose_name='Post Data')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_posts', to='django_paypal.PaypalOrder')), + ], + ), + ] diff --git a/django_paypal/models.py b/django_paypal/models.py index eddf5b4..3ac1919 100644 --- a/django_paypal/models.py +++ b/django_paypal/models.py @@ -1,38 +1,69 @@ from __future__ import unicode_literals -import json -import logging from decimal import Decimal from django.db import models -from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import gettext_lazy as _ +try: + # Django 3.1 and newer + from django.db.models import JSONField +except ImportError: + # Django 2.2 + from django.contrib.postgres.fields import JSONField -@python_2_unicode_compatible -class PaypalPayment(models.Model): - payment_id = models.CharField(_("payment id"), max_length=255, unique=True) # id created by paypal - intent = models.CharField(_("intent"), max_length=255) # sale - experience_profile_id = models.CharField(_("intent"), max_length=255, blank=True) # sale - note_to_payer = models.CharField(_("intent"), max_length=165, blank=True) # sale - state = models.CharField(_("state"), max_length=255, blank=True) - payment_method = models.CharField(_("payment method"), max_length=255, blank=True) - custom = models.CharField(_("payment method"), max_length=255, blank=True) - payer_id = models.CharField(_("payer id"), max_length=255, blank=True) - transaction_fee = models.DecimalField(_("transaction fee"), max_digits=9, decimal_places=2, default=Decimal(0)) - related_resource_id = models.CharField(_("related resource id"), max_length=255, blank=True, null=True) - related_resource_state = models.CharField(_("related resource state"), max_length=255, blank=True, null=True) - initial_response_object = models.TextField(_("initial post response"), null=True, blank=True) - update_response_object = models.TextField(_("updated get response"), null=True, blank=True) - failure_reason = models.CharField(_("failure reason"), max_length=255, blank=True) +class PaypalOrder(models.Model): + order_id = models.CharField(_('Order ID'), max_length=255, unique=True) + status = models.CharField(_('Status'), max_length=255, blank=True) - link = models.URLField(_("link"), blank=True) - approve_link = models.URLField(_("approve link"), blank=True) - execute_link = models.URLField(_("execute link"), blank=True) - created_at = models.DateTimeField(_("created at"), auto_now_add=True) - last_modified = models.DateTimeField(_("last modified"), auto_now=True) +class PaypalAPIResponse(models.Model): + order = models.ForeignKey(PaypalOrder, related_name='api_responses', on_delete=models.CASCADE) + url = models.CharField(_('URL'), max_length=255, blank=True) + response_data = JSONField(_('Response Data')) + created_at = models.DateTimeField(auto_now_add=True) + + +class PaypalAPIPostData(models.Model): + order = models.ForeignKey(PaypalOrder, related_name='api_posts', on_delete=models.CASCADE) + url = models.CharField(_('URL'), max_length=255, blank=True) + post_data = JSONField(_('Post Data')) + created_at = models.DateTimeField(auto_now_add=True) + + +class PaypalWebhook(models.Model): + webhook_id = models.CharField(_('Webhook ID'), max_length=255, unique=True) + url = models.CharField('URL', max_length=255, unique=True) + events = JSONField(_('Active Webhook Events')) + + +# The following models solely exist to keep payments made in version <0.3.0 stored in the database. +# They are not used in the current version of the app and have been created using /v1/payments. +# They might get removed in a future version. +class LegacyPaypalPayment(models.Model): + payment_id = models.CharField(_('payment id'), max_length=255, unique=True) # id created by paypal + intent = models.CharField(_('intent'), max_length=255) # sale + experience_profile_id = models.CharField(_('intent'), max_length=255, blank=True) # sale + note_to_payer = models.CharField(_('intent'), max_length=165, blank=True) # sale + state = models.CharField(_('state'), max_length=255, blank=True) + payment_method = models.CharField(_('payment method'), max_length=255, blank=True) + custom = models.CharField(_('payment method'), max_length=255, blank=True) + payer_id = models.CharField(_('payer id'), max_length=255, blank=True) + transaction_fee = models.DecimalField(_('transaction fee'), max_digits=9, decimal_places=2, default=Decimal(0)) + related_resource_id = models.CharField(_('related resource id'), max_length=255, blank=True, null=True) + related_resource_state = models.CharField(_('related resource state'), max_length=255, blank=True, null=True) + initial_response_object = models.TextField(_('initial post response'), null=True, blank=True) + update_response_object = models.TextField(_('updated get response'), null=True, blank=True) + + failure_reason = models.CharField(_('failure reason'), max_length=255, blank=True) + + link = models.URLField(_('link'), blank=True) + approve_link = models.URLField(_('approve link'), blank=True) + execute_link = models.URLField(_('execute link'), blank=True) + + created_at = models.DateTimeField(_('created at'), auto_now_add=True) + last_modified = models.DateTimeField(_('last modified'), auto_now=True) objects = models.Manager() @@ -40,116 +71,57 @@ def __str__(self): return self.payment_id class Meta: - verbose_name = _("Paypal Payment") - verbose_name_plural = _("Paypal Payments") - - def execute(self, paypal_wrapper): - if not self.execute_link: - return False - if self.state == 'approved': - return False - if not self.payer_id: - return False - execute_response = paypal_wrapper.call_api(url=self.execute_link, data={'payer_id': self.payer_id}) - if execute_response and 'state' in execute_response and execute_response['state'] == 'approved': - self.state = 'approved' - self.save() - return True - return False - - def refresh_from_paypal(self, paypal_wrapper, expected_status=None): - payment_response = paypal_wrapper.call_api(url=self.link) - if not payment_response: - logger = logging.getLogger(__name__) - logger.error("Paypal Payment Link not available: {}".format(self.link)) - return False - - if expected_status and expected_status != payment_response['state']: - logger = logging.getLogger(__name__) - logger.error("Paypal Payment Status Error: expected: {0}, found: {1}".format(expected_status, payment_response['state'])) - return False - - self.update_response_object = json.dumps(payment_response) - - if payment_response['state'] == 'approved': - fee = Decimal(0) - for transaction in payment_response['transactions']: - if 'related_resources' in transaction: - for resource in transaction['related_resources']: - if 'sale' in resource: - if 'links' in resource['sale']: - for link in resource['sale']['links']: - if 'rel' in link and link['rel'] == 'self': - sale_response = paypal_wrapper.call_api(url=link['href']) - if sale_response and 'transaction_fee' in sale_response and 'value' in sale_response['transaction_fee'] and sale_response['transaction_fee']['currency'] == 'EUR': - self.transaction_fee = Decimal(sale_response['transaction_fee']['value']) - if 'id' in resource['sale']: - self.related_resource_id = resource['sale']['id'] - if 'state' in resource['sale']: - self.related_resource_state = resource['sale']['state'] - - if 'payer' in payment_response and 'payer_info' in payment_response['payer'] and 'payer_id' in payment_response['payer']['payer_info']: - self.payer_id = payment_response['payer']['payer_info']['payer_id'] - - if 'failure_reason' in payment_response: - self.failure_reason = payment_response['failure_reason'] - - self.state = payment_response['state'] - - if 'links' in payment_response: - for link in payment_response['links']: - if 'rel' in link: - if link['rel'] == "self": - self.link = link['href'] - elif link['rel'] == "approval_url": - self.approve_link = link['href'] - elif link['rel'] == "execute": - self.execute_link = link['href'] - self.save() - - return self - - -@python_2_unicode_compatible -class PaypalTransaction(models.Model): - payment = models.ForeignKey(PaypalPayment, verbose_name=_("payment"), related_name='transactions', on_delete=models.PROTECT) - - total_amount = models.DecimalField(_("total amount"), max_digits=9, decimal_places=2) - currency = models.CharField(_("currency"), max_length=10) - subtotal = models.DecimalField(_("subtotal"), max_digits=9, decimal_places=2) - tax = models.DecimalField(_("tax"), max_digits=9, decimal_places=2, blank=True) - shipping = models.DecimalField(_("shipping"), max_digits=9, decimal_places=2, blank=True) - handling_fee = models.DecimalField(_("handling fee"), max_digits=9, decimal_places=2, blank=True) - shipping_discount = models.DecimalField(_("shipping discount"), max_digits=9, decimal_places=2, blank=True) - insurance = models.DecimalField(_("insurance fee"), max_digits=9, decimal_places=2, blank=True) - gift_wrap = models.DecimalField(_("gift wrap fee"), max_digits=9, decimal_places=2, blank=True) - - reference_id = models.CharField(_("reference id"), max_length=255, blank=True) - settlement_destination = models.CharField(_("settlement destination"), max_length=255, default="PARTNER_BALANCE") - allowed_payment_method = models.CharField(_("allowed payment method"), max_length=255, default="INSTANT_FUNDING_SOURCE") # UNRESTRICTED or INSTANT_FUNDING_SOURCE or IMMEDIATE_PAY - description = models.CharField(_("description"), max_length=255, blank=True) - note_to_payee = models.CharField(_("note to payee"), max_length=255, blank=True) - custom = models.CharField(_("custom"), max_length=127, blank=True) - invoice_number = models.CharField(_("invoice number"), max_length=127) - purchase_order = models.CharField(_("purchase order"), max_length=127) - soft_descriptor = models.CharField(_("soft descriptor"), max_length=22) - - created_at = models.DateTimeField(_("created at"), auto_now_add=True) - last_modified = models.DateTimeField(_("last modified"), auto_now=True) - - shipping_method = models.CharField(_("shipping method"), max_length=255, blank=True) - shipping_phone_number = models.CharField(_("shipping phone number"), max_length=255, blank=True) - - shipping_address_line_1 = models.CharField(_("shipping address line 1"), max_length=100) # number, street, etc. - shipping_address_line_2 = models.CharField(_("shipping address line 2"), max_length=100, blank=True) # apt number, etc. - shipping_address_city = models.CharField(_("shipping address city"), max_length=50) - shipping_address_country = models.CharField(_("shipping address country"), max_length=2) - shipping_address_postal_code = models.CharField(_("shipping address postal code"), max_length=20, blank=True) # required in certain countries - shipping_address_state = models.CharField(_("shipping address state"), max_length=100, blank=True) - shipping_address_phone = models.CharField(_("shipping address phone"), max_length=50) - shipping_address_normalization_status = models.CharField(_("shipping address normalization status"), max_length=50, default="UNKNOWN") # UNKNOWN or UNNORMALIZED_USER_PREFERRED or NORMALIZED or UNNORMALIZED - shipping_address_type = models.CharField(_("shipping address type"), max_length=50, default="HOME_OR_WORK") # HOME_OR_WORK or GIFT or other - shipping_address_recipient_name = models.CharField(_("shipping address recipient name"), max_length=127) + verbose_name = _('Legacy Paypal Payment (payments v1 API)') + verbose_name_plural = _('Legacy Paypal Payments (payments v1 API)') + + +class LegacyPaypalTransaction(models.Model): + payment = models.ForeignKey(LegacyPaypalPayment, verbose_name=_('payment'), related_name='transactions', on_delete=models.PROTECT) + + total_amount = models.DecimalField(_('total amount'), max_digits=9, decimal_places=2) + currency = models.CharField(_('currency'), max_length=10) + subtotal = models.DecimalField(_('subtotal'), max_digits=9, decimal_places=2) + tax = models.DecimalField(_('tax'), max_digits=9, decimal_places=2, blank=True) + shipping = models.DecimalField(_('shipping'), max_digits=9, decimal_places=2, blank=True) + handling_fee = models.DecimalField(_('handling fee'), max_digits=9, decimal_places=2, blank=True) + shipping_discount = models.DecimalField(_('shipping discount'), max_digits=9, decimal_places=2, blank=True) + insurance = models.DecimalField(_('insurance fee'), max_digits=9, decimal_places=2, blank=True) + gift_wrap = models.DecimalField(_('gift wrap fee'), max_digits=9, decimal_places=2, blank=True) + + reference_id = models.CharField(_('reference id'), max_length=255, blank=True) + settlement_destination = models.CharField(_('settlement destination'), max_length=255, default='PARTNER_BALANCE') + allowed_payment_method = models.CharField( + _('allowed payment method'), max_length=255, default='INSTANT_FUNDING_SOURCE' + ) # UNRESTRICTED or INSTANT_FUNDING_SOURCE or IMMEDIATE_PAY + description = models.CharField(_('description'), max_length=255, blank=True) + note_to_payee = models.CharField(_('note to payee'), max_length=255, blank=True) + custom = models.CharField(_('custom'), max_length=127, blank=True) + invoice_number = models.CharField(_('invoice number'), max_length=127) + purchase_order = models.CharField(_('purchase order'), max_length=127) + soft_descriptor = models.CharField(_('soft descriptor'), max_length=22) + + created_at = models.DateTimeField(_('created at'), auto_now_add=True) + last_modified = models.DateTimeField(_('last modified'), auto_now=True) + + shipping_method = models.CharField(_('shipping method'), max_length=255, blank=True) + shipping_phone_number = models.CharField(_('shipping phone number'), max_length=255, blank=True) + + shipping_address_line_1 = models.CharField(_('shipping address line 1'), max_length=100) # number, street, etc. + shipping_address_line_2 = models.CharField(_('shipping address line 2'), max_length=100, blank=True) # apt number, etc. + shipping_address_city = models.CharField(_('shipping address city'), max_length=50) + shipping_address_country = models.CharField(_('shipping address country'), max_length=2) + shipping_address_postal_code = models.CharField( + _('shipping address postal code'), max_length=20, blank=True + ) # required in certain countries + shipping_address_state = models.CharField(_('shipping address state'), max_length=100, blank=True) + shipping_address_phone = models.CharField(_('shipping address phone'), max_length=50) + shipping_address_normalization_status = models.CharField( + _('shipping address normalization status'), max_length=50, default='UNKNOWN' + ) # UNKNOWN or UNNORMALIZED_USER_PREFERRED or NORMALIZED or UNNORMALIZED + shipping_address_type = models.CharField( + _('shipping address type'), max_length=50, default='HOME_OR_WORK' + ) # HOME_OR_WORK or GIFT or other + shipping_address_recipient_name = models.CharField(_('shipping address recipient name'), max_length=127) objects = models.Manager() @@ -157,25 +129,24 @@ def __str__(self): return '{} - {} {}'.format(self.reference_id, self.total_amount, self.currency) class Meta: - verbose_name = _("Paypal Transaction") - verbose_name_plural = _("Paypal Transactions") + verbose_name = _('Legacy Paypal Transaction (payments v1 API)') + verbose_name_plural = _('Legacy Paypal Transactions (payments v1 API)') -@python_2_unicode_compatible -class PaypalItem(models.Model): - transaction = models.ForeignKey(PaypalTransaction, verbose_name=_("transaction"), related_name='items', on_delete=models.PROTECT) +class LegacyPaypalItem(models.Model): + transaction = models.ForeignKey(LegacyPaypalTransaction, verbose_name=_('transaction'), related_name='items', on_delete=models.PROTECT) - sku = models.CharField(_("stock keeping unit"), max_length=127) - name = models.CharField(_("name"), max_length=127) - description = models.CharField(_("description"), max_length=127, blank=True) - quantity = models.CharField(_("quantity"), max_length=10) - price = models.CharField(_("price"), max_length=10) - currency = models.CharField(_("currency"), max_length=3) - tax = models.CharField(_("currency"), max_length=10, blank=True) - url = models.URLField(_("url"), blank=True) + sku = models.CharField(_('stock keeping unit'), max_length=127) + name = models.CharField(_('name'), max_length=127) + description = models.CharField(_('description'), max_length=127, blank=True) + quantity = models.CharField(_('quantity'), max_length=10) + price = models.CharField(_('price'), max_length=10) + currency = models.CharField(_('currency'), max_length=3) + tax = models.CharField(_('currency'), max_length=10, blank=True) + url = models.URLField(_('url'), blank=True) - created_at = models.DateTimeField(_("created at"), auto_now_add=True) - last_modified = models.DateTimeField(_("last modified"), auto_now=True) + created_at = models.DateTimeField(_('created at'), auto_now_add=True) + last_modified = models.DateTimeField(_('last modified'), auto_now=True) objects = models.Manager() @@ -183,5 +154,5 @@ def __str__(self): return self.name class Meta: - verbose_name = _("Paypal Item") - verbose_name_plural = _("Paypal Item") + verbose_name = _('Legacy Paypal Item (payments v1 API)') + verbose_name_plural = _('Legacy Paypal Item (payments v1 API)') diff --git a/django_paypal/ruff.toml b/django_paypal/ruff.toml new file mode 100644 index 0000000..36ad610 --- /dev/null +++ b/django_paypal/ruff.toml @@ -0,0 +1,60 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "frontend", + "migrations", +] + +line-length = 140 +indent-width = 4 + +# Assume Python 3.8 +target-version = "py38" + +[lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or +# McCabe complexity (`C901`) by default. +select = ["E4", "E7", "E9", "F", "T20"] +ignore = [] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +ignore-init-module-imports = true + +[format] +# Like Black, use double quotes for strings. +quote-style = "single" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" diff --git a/django_paypal/settings.py b/django_paypal/settings.py index 3981ccb..a57d193 100644 --- a/django_paypal/settings.py +++ b/django_paypal/settings.py @@ -7,12 +7,15 @@ PAYPAL_API_CLIENT_ID = getattr(settings, 'PAYPAL_API_CLIENT_ID', False) PAYPAL_API_SECRET = getattr(settings, 'PAYPAL_API_SECRET', False) -PAYPAL_API_URL = getattr(settings, 'PAYPAL_API_URL', 'https://api.paypal.com') -PAYPAL_SANDBOX_API_URL = getattr(settings, 'PAYPAL_SANDBOX_API_URL', 'https://api.sandbox.paypal.com') +PAYPAL_API_URL = getattr(settings, 'PAYPAL_API_URL', 'https://api-m.paypal.com') +PAYPAL_SANDBOX_API_URL = getattr(settings, 'PAYPAL_SANDBOX_API_URL', 'https://api-m.sandbox.paypal.com') PAYPAL_SANDBOX = getattr(settings, 'PAYPAL_SANDBOX', True) PAYPAL_AUTH_URL = getattr(settings, 'PAYPAL_AUTH_URL', '/v1/oauth2/token') -PAYPAL_PAYMENT_URL = getattr(settings, 'PAYPAL_PAYMENT_URL', '/v1/payments/payment') +PAYPAL_AUTH_CACHE_TIMEOUT = getattr(settings, 'PAYPAL_AUTH_CACHE_TIMEOUT', 600) # 10 minutes +PAYPAL_AUTH_CACHE_KEY = getattr(settings, 'PAYPAL_AUTH_CACHE_KEY', 'django-paypal-auth') +PAYPAL_ORDERS_API_ENDPOINT = getattr(settings, 'PAYPAL_ORDERS_API_ENDPOINT', '/v2/checkout/orders') +PAYPAL_WEBHOOK_LISTENER = getattr(settings, 'PAYPAL_WEBHOOK_LISTENER', None) # checkout urls PAYPAL_SUCCESS_URL = getattr(settings, 'PAYPAL_SUCCESS_URL', '/') diff --git a/django_paypal/signals.py b/django_paypal/signals.py new file mode 100644 index 0000000..d8aa7e6 --- /dev/null +++ b/django_paypal/signals.py @@ -0,0 +1,6 @@ +import django.dispatch + +order_created = django.dispatch.Signal() +order_approved = django.dispatch.Signal() +order_captured = django.dispatch.Signal() +order_completed = django.dispatch.Signal() diff --git a/django_paypal/urls.py b/django_paypal/urls.py index f327f1a..f209001 100644 --- a/django_paypal/urls.py +++ b/django_paypal/urls.py @@ -1,7 +1,7 @@ -from django.conf.urls import url +from django.urls import re_path -from .views import NotifyPaypalView +from .webhooks import PaypalWebhookView urlpatterns = [ - url(r'^notify/$', NotifyPaypalView.as_view(), name='notifiy'), + re_path(r'^webhooks/$', PaypalWebhookView.as_view(), name='paypal-webhooks'), ] diff --git a/django_paypal/views.py b/django_paypal/views.py deleted file mode 100644 index b7704aa..0000000 --- a/django_paypal/views.py +++ /dev/null @@ -1,56 +0,0 @@ -import json - -from django.http import HttpResponse -from django.utils.translation import gettext_lazy as _ -from django.views.decorators.csrf import csrf_exempt -from django.views.generic import View - -from .wrappers import PaypalWrapper -from .models import PaypalPayment -from django_paypal import settings as django_paypal_settings - - -class NotifyPaypalView(View): - paypal_wrapper = PaypalWrapper(auth={ - 'API_CLIENT_ID': django_paypal_settings.PAYPAL_API_CLIENT_ID, - 'API_SECRET': django_paypal_settings.PAYPAL_API_SECRET, - }) - - def post(self, request, *args, **kwargs): - request_data = json.loads(request.body.decode('utf-8')) - - # return HttpResponse(status=200) - # general attributes - if 'resource' not in request_data: - return HttpResponse(status=400) - payment_id = None - for key in ['parent_payment', 'id']: - if key in request_data['resource']: - payment_id = request_data['resource'][key] - break - if payment_id is None: - return HttpResponse(status=400) - - try: - paypal_payment = PaypalPayment.objects.get(payment_id=payment_id) - except PaypalPayment.DoesNotExist: - return HttpResponse(status=400) - return self.handle_updated_payment(paypal_payment=paypal_payment, expected_status='approved') - - @csrf_exempt - def dispatch(self, request, *args, **kwargs): - return super(NotifyPaypalView, self).dispatch(request, *args, **kwargs) - - def handle_updated_payment(self, paypal_payment, expected_status=None): - """ - Override to use the paypal_payment in the way you want. - """ - updated_payment = paypal_payment - if updated_payment.refresh_from_paypal(self.paypal_wrapper, expected_status=expected_status): - if updated_payment.status == 'approved': - import logging - logger = logging.getLogger(__name__) - logger.error(_('Paypal: Status of checkout {} is now {}').format(updated_payment.payment_id, updated_payment.status)) - return HttpResponse(status=400) - return HttpResponse(status=200) - return HttpResponse(status=400) diff --git a/django_paypal/webhooks.py b/django_paypal/webhooks.py new file mode 100644 index 0000000..a8282a0 --- /dev/null +++ b/django_paypal/webhooks.py @@ -0,0 +1,48 @@ +import json + +from django.conf import settings +from django.http import HttpResponse, HttpRequest +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import View + +from .api_types import APIAuthCredentials +from .models import PaypalWebhook +from .signals import order_approved, order_completed +from .wrappers import PaypalWrapper +from django_paypal import settings as django_paypal_settings + + +class WebhookEvents: + ORDERS = ['CHECKOUT.ORDER.COMPLETED', 'CHECKOUT.ORDER.APPROVED', 'CHECKOUT.PAYMENT-APPROVAL.REVERSED'] + + +def verify_webhook(request: HttpRequest, paypal_wrapper: PaypalWrapper): + if not settings.DEBUG: + paypal_webhook = PaypalWebhook.objects.get(url=request.build_absolute_uri()) + paypal_wrapper.verify_webhook_event(request, paypal_webhook.webhook_id) + + +@method_decorator(csrf_exempt, name='dispatch') +class PaypalWebhookView(View): + def post(self, request, *args, **kwargs): + post_dict = json.loads(request.body.decode('utf-8')) + event_type = post_dict.get('event_type') + if event_type not in WebhookEvents.ORDERS: + return HttpResponse(status=400) + + paypal_wrapper = PaypalWrapper( + auth=APIAuthCredentials( + client_id=django_paypal_settings.PAYPAL_API_CLIENT_ID, client_secret=django_paypal_settings.PAYPAL_API_SECRET + ) + ) + + verify_webhook(request, paypal_wrapper) + + if event_type == 'CHECKOUT.ORDER.APPROVED': + order_approved.send(sender=self.__class__, resource=post_dict.get('resource')) + paypal_wrapper.capture_order(post_dict.get('resource').get('id')) + if event_type == 'CHECKOUT.ORDER.COMPLETED': + order_completed.send(sender=self.__class__, resource=post_dict.get('resource')) + + return HttpResponse(status=200) diff --git a/django_paypal/wrappers.py b/django_paypal/wrappers.py index 846444d..b13644f 100644 --- a/django_paypal/wrappers.py +++ b/django_paypal/wrappers.py @@ -1,27 +1,35 @@ from __future__ import unicode_literals -import base64 import json -import logging -import os -import sys -from decimal import Decimal +import warnings +from typing import Literal, List, Any, Dict -import certifi -from django.conf import settings +from django.core.cache import cache +import requests +from django.http import HttpRequest +from requests.auth import HTTPBasicAuth from django_paypal import settings as django_paypal_settings -from django_paypal.models import PaypalTransaction, PaypalPayment, PaypalItem +from django_paypal.exceptions import PaypalAuthFailure, PaypalAPIError, PaypalWebhookVerificationError +from django_paypal.models import ( + PaypalOrder, + PaypalAPIPostData, + PaypalAPIResponse, + PaypalWebhook, +) +from django_paypal.api_types import ( + OAuthResponse, + PaymentSource, + ApplicationContext, + ExperienceContext, + PurchaseUnit, + APIAuthCredentials, + OrderCreatedAPIResponse, + OrderCaptureAPIResponse, + OrderDetailAPIResponse, +) from django_paypal.utils import build_paypal_full_uri - -try: - # For Python 3.0 and later - from urllib.error import HTTPError - from urllib.request import urlopen - from urllib.request import Request -except ImportError: - # Fall back to Python 2's urllib2 - from urllib2 import HTTPError, Request, urlopen +from django_paypal.signals import order_captured, order_created class PaypalWrapper(object): @@ -29,197 +37,172 @@ class PaypalWrapper(object): api_url = django_paypal_settings.PAYPAL_API_URL sandbox_url = django_paypal_settings.PAYPAL_SANDBOX_API_URL - payment_url = django_paypal_settings.PAYPAL_PAYMENT_URL + orders_api_endpoint = django_paypal_settings.PAYPAL_ORDERS_API_ENDPOINT auth_url = django_paypal_settings.PAYPAL_AUTH_URL + auth_cache_timeout = django_paypal_settings.PAYPAL_AUTH_CACHE_TIMEOUT + auth_cache_key = django_paypal_settings.PAYPAL_AUTH_CACHE_KEY - auth = None + auth: APIAuthCredentials = None - def __init__(self, auth=None, sandbox=None): + def __init__(self, auth: APIAuthCredentials, sandbox=None): super(PaypalWrapper, self).__init__() - if getattr(settings, 'PAYPAL', False): - self.auth = auth - if sandbox is not None: - if sandbox: - self.api_url = self.sandbox_url - else: - if django_paypal_settings.PAYPAL_SANDBOX: - self.api_url = self.sandbox_url - - def init(self, intent, payer, transactions, note_to_payer=None, experience_profile_id=None, - success_url=django_paypal_settings.PAYPAL_SUCCESS_URL, - cancellation_url=django_paypal_settings.PAYPAL_CANCELLATION_URL, - notification_url=django_paypal_settings.PAYPAL_NOTIFICATION_URL): - - if not self.auth: - return False - - # for transaction in transactions: - # transaction['notify_url'] = build_paypal_full_uri(notification_url) - payment_data = { - 'intent': intent, - 'payer': payer, - 'transactions': transactions - } - if experience_profile_id: - payment_data.update({'experience_profile_id': experience_profile_id}) - if note_to_payer: - payment_data.update({'note_to_payer': note_to_payer}) - if note_to_payer: - payment_data.update( - {'redirect_urls': - {'return_url': build_paypal_full_uri(success_url), - 'cancel_url': build_paypal_full_uri(cancellation_url)} - }) - - payment_response = self.call_api(url=self.payment_url, data=payment_data) - - if payment_response and 'state' in payment_response and payment_response['state'] == 'created': - - transaction_array = [] - items_dict = {} - try: - paypal_payment = PaypalPayment( - state=payment_response['state'], - payment_id=payment_response['id'], - intent=payment_response['intent'], - experience_profile_id=payment_response.get('experience_profile_id', ''), - note_to_payer=payment_response.get('note_to_payer', ''), - payment_method=payment_response['payer'].get('payment_method', ''), - custom=payment_response.get('custom', ''), - initial_response_object=json.dumps(payment_response), + self.auth = auth + if sandbox or django_paypal_settings.PAYPAL_SANDBOX: + self.api_url = self.sandbox_url + + def create_order( + self, + intent: Literal['CAPTURE', 'AUTHORIZE'], + purchase_units: List[PurchaseUnit], + payment_source: PaymentSource, + application_context: ApplicationContext = None, + success_url=django_paypal_settings.PAYPAL_SUCCESS_URL, + cancellation_url=django_paypal_settings.PAYPAL_CANCELLATION_URL, + ) -> OrderCreatedAPIResponse: + if payment_source.paypal and (success_url or cancellation_url): + if not payment_source.paypal.experience_context: + payment_source.paypal.experience_context = ExperienceContext( + return_url=build_paypal_full_uri(success_url), + cancel_url=build_paypal_full_uri(cancellation_url), ) - if 'links' in payment_response: - for link in payment_response['links']: - if 'rel' in link: - if link['rel'] == "self": - paypal_payment.link = link['href'] - elif link['rel'] == "approval_url": - paypal_payment.approve_link = link['href'] - elif link['rel'] == "execute": - paypal_payment.execute_link = link['href'] - - for transaction in payment_response['transactions']: - transaction_array.append(PaypalTransaction( - reference_id=transaction['reference_id'], - total_amount=Decimal(transaction['amount']['total']), - currency=transaction['amount']['currency'], - subtotal=Decimal(transaction['amount']['details'].get('subtotal', 0)), - tax=Decimal(transaction['amount']['details'].get('tax', 0)), - shipping=Decimal(transaction['amount']['details'].get('shipping', 0)), - handling_fee=Decimal(transaction['amount']['details'].get('handling_fee', 0)), - shipping_discount=Decimal(transaction['amount']['details'].get('shipping_discount', 0)), - insurance=Decimal(transaction['amount']['details'].get('insurance', 0)), - gift_wrap=Decimal(transaction['amount']['details'].get('gift_wrap', 0)), - settlement_destination=transaction.get('settlement_destination', ''), - allowed_payment_method=transaction['payment_options'].get('allowed_payment_method', ''), - description=transaction.get('description', ''), - note_to_payee=transaction.get('note_to_payee', ''), - custom=transaction.get('custom', ''), - invoice_number=transaction.get('invoice_number', ''), - purchase_order=transaction.get('purchase_order', ''), - soft_descriptor=transaction.get('soft_descriptor', ''), - shipping_method=transaction['item_list'].get('shipping_method', ''), - shipping_phone_number=transaction['item_list'].get('shipping_phone_number', ''), - shipping_address_line_1=transaction['item_list']['shipping_address'].get('line_1', ''), - shipping_address_line_2=transaction['item_list']['shipping_address'].get('line_2', ''), - shipping_address_city=transaction['item_list']['shipping_address'].get('city', ''), - shipping_address_country=transaction['item_list']['shipping_address'].get('country', ''), - shipping_address_postal_code=transaction['item_list']['shipping_address'].get('postal_code', ''), - shipping_address_state=transaction['item_list']['shipping_address'].get('state', ''), - shipping_address_phone=transaction['item_list']['shipping_address'].get('phone', ''), - shipping_address_normalization_status=transaction['item_list']['shipping_address'].get('normalization_status', ''), - shipping_address_type=transaction['item_list']['shipping_address'].get('type', ''), - shipping_address_recipient_name=transaction['item_list']['shipping_address'].get('recipient_name', ''), - )) - for item in transaction['item_list']['items']: - if transaction['reference_id'] not in items_dict: - items_dict[transaction['reference_id']] = [] - items_dict[transaction['reference_id']].append(PaypalItem( - sku=item.get('sku', ''), - name=item.get('name', ''), - description=item['description'], - quantity=item['quantity'], - price=item['price'], - currency=item['currency'], - tax=item.get('tax', ''), - url=item.get('url', '') - )) - except KeyError as e: - return False - paypal_payment.save() - for transaction in transaction_array: - transaction.payment = paypal_payment - transaction.save() - if transaction.reference_id in items_dict: - for item in items_dict[transaction.reference_id]: - item.transaction = transaction - item.save() - return paypal_payment - else: - return False - - def call_api(self, url=None, access_token=None, data=None): - if not self.auth: - return False - if not url.lower().startswith('http'): - url = '{0}{1}'.format(self.api_url, url) - request = Request(url) - - if access_token is None: - access_token = self._get_access_token() - request.add_header('Authorization', 'Bearer {0}'.format(access_token)) - if data: - data = json.dumps(data) - data_len = len(data) - request.add_header('Content-Length', data_len) - request.data = data.encode(encoding='utf-8') - elif data == '': - request.method = 'POST' - request.data = ''.encode(encoding='utf-8') - request.add_header('Content-Type', 'application/json') - try: - if sys.version_info.major > 2 or (sys.version_info.major == 2 and sys.version_info.major > 7 or (sys.version_info.major == 7 and sys.version_info.major >= 9)): - response = urlopen(request, cafile=certifi.where()) else: - response = urlopen(request) - except HTTPError as e: - logger = logging.getLogger(__name__) - fp = e.fp - body = fp.read() - fp.close() - if hasattr(e, 'code'): - logger.error("Paypal Error {0}({1}): {2}".format(e.code, e.msg, body)) + if not payment_source.paypal.experience_context.return_url: + payment_source.paypal.experience_context.return_url = build_paypal_full_uri(success_url) + if not payment_source.paypal.experience_context.cancel_url: + payment_source.paypal.experience_context.cancel_url = build_paypal_full_uri(cancellation_url) + + order_data = {'intent': intent, 'purchase_units': [purchase_unit.to_dict() for purchase_unit in purchase_units]} + if payment_source: + order_data.update({'payment_source': payment_source.to_dict()}) + if application_context: + order_data.update({'application_context': application_context.to_dict()}) + + url = '{0}{1}'.format(self.api_url, self.orders_api_endpoint) + order_response_dict = self.call_api(url=url, data=order_data, method='POST') + order_response = OrderCreatedAPIResponse.from_dict(order_response_dict) + new_order = PaypalOrder.objects.create(order_id=order_response.id, status=order_response.status) + PaypalAPIPostData.objects.create(order=new_order, url=url, post_data=order_data) + PaypalAPIResponse.objects.create(order=new_order, url=url, response_data=order_response_dict) + order_created.send(sender=self.__class__, order=new_order, response=order_response_dict) + return order_response + + def capture_order(self, order_id: str) -> OrderCaptureAPIResponse: + order = PaypalOrder.objects.get(order_id=order_id) + url = f'{self.api_url}{self.orders_api_endpoint}/{order.order_id}/capture' + order_capture_response = self.call_api(url=url, method='POST') + order_capture = OrderCaptureAPIResponse.from_dict(order_capture_response) + order.status = order_capture.status + order.save(update_fields=['status']) + PaypalAPIPostData.objects.create(order=order, url=url, post_data={}) + PaypalAPIResponse.objects.create(order=order, url=url, response_data=order_capture_response) + order_captured.send(sender=self.__class__, order=order, response=order_capture_response) + return order_capture + + def get_order_details(self, order_id: str) -> OrderDetailAPIResponse: + order = PaypalOrder.objects.get(order_id=order_id) + url = f'{self.api_url}{self.orders_api_endpoint}/{order.order_id}' + order_details_response = self.call_api(url=url, method='GET') + return OrderDetailAPIResponse.from_dict(order_details_response) + + def call_api(self, url: str, method: Literal['GET', 'POST', 'PATCH'], data=None) -> Dict[str, Any]: + headers = {'Authorization': f'Bearer {self._get_access_token()}', 'Content-Type': 'application/json'} + + try: + if method == 'GET': + response = requests.get(url, headers=headers) + elif method == 'POST': + response = requests.post(url, headers=headers, json=data) + elif method == 'PATCH': + response = requests.patch(url, headers=headers, json=data) else: - logger.error("Paypal Error({0}): {1}".format(e.msg, body)) + raise ValueError('Invalid method') + response.raise_for_status() + response_json = response.json() + return response_json + except requests.HTTPError as e: + raise PaypalAPIError(str(e), response=e.response) + + def setup_webhooks(self, webhook_listener: str) -> PaypalWebhook: + if PaypalWebhook.objects.filter(url=webhook_listener).exists(): + raise ValueError(f'Webhook listener ({webhook_listener}) already exists') + from django_paypal.webhooks import WebhookEvents + + data = {'url': webhook_listener, 'event_types': [{'name': event} for event in WebhookEvents.ORDERS]} + webhook_api = '{0}{1}'.format(self.api_url, '/v1/notifications/webhooks') + try: + response_dict = self.call_api(url=webhook_api, method='POST', data=data) + return PaypalWebhook.objects.create( + webhook_id=response_dict['id'], url=response_dict['url'], events=response_dict['event_types'] + ) + except PaypalAPIError as e: + response_json = e.response.json() + if response_json.get('name') == 'WEBHOOK_URL_ALREADY_EXISTS': + webhook_list = self.call_api(url=webhook_api, method='GET') + for webhook in webhook_list.get('webhooks', []): + if webhook['url'] == webhook_listener: + return PaypalWebhook.objects.create(webhook_id=webhook['id'], url=webhook['url'], events=webhook['event_types']) + raise e + + def patch_webhook(self, webhook_id: str, patch_data: List[Dict]) -> PaypalWebhook: + try: + paypal_webhook = PaypalWebhook.objects.get(webhook_id=webhook_id) + except PaypalWebhook.DoesNotExist as e: + warnings.warn(f'Webhook with id {webhook_id} does not exist in the database. Set up webhooks by calling "setup_webhooks"') + raise e + url = '{0}{1}'.format(self.api_url, f'/v1/notifications/webhooks/{webhook_id}') + webhook_response_dict = self.call_api(url=url, method='PATCH', data=patch_data) + paypal_webhook.url = webhook_response_dict['url'] + paypal_webhook.events = webhook_response_dict['event_types'] + paypal_webhook.save() + return paypal_webhook + + def verify_webhook_event(self, request: HttpRequest, webhook_id: str) -> bool: + headers = request.headers + auth_algo = headers.get('Paypal-Auth-Algo') + cert_url = headers.get('Paypal-Cert-Url') + transmission_id = headers.get('Paypal-Transmission-Id') + transmission_time = headers.get('Paypal-Transmission-Time') + transmission_sig = headers.get('Paypal-Transmission-Sig') + request_data = json.loads(request.body.decode('utf-8')) + verification_payload = { + 'auth_algo': auth_algo, + 'cert_url': cert_url, + 'transmission_id': transmission_id, + 'transmission_time': transmission_time, + 'transmission_sig': transmission_sig, + 'webhook_id': webhook_id, + 'webhook_event': request_data, + } + url = f'{self.api_url}/v1/notifications/verify-webhook-signature' + res = self.call_api(url=url, method='POST', data=verification_payload) + if res.get('verification_status') == 'SUCCESS': + return True + raise PaypalWebhookVerificationError('Webhook verification failed', res) + + def _get_access_token(self) -> str: + if not self.auth_cache_timeout: + auth_response = self._authorize_client() + return auth_response.access_token else: - return json.loads(response.read().decode('utf-8')) - return False - - def _get_access_token(self): - url = '{0}{1}'.format(self.api_url, self.auth_url) - request = Request(url=url) + access_token = cache.get(self.auth_cache_key) + if not access_token: + auth_response = self._authorize_client() + access_token = auth_response.access_token + cache.set(self.auth_cache_key, access_token, self.auth_cache_timeout) + return access_token + + def _authorize_client(self) -> OAuthResponse: # preparing request - base64string = base64.encodestring(('%s:%s' % (self.auth['API_CLIENT_ID'], self.auth['API_SECRET'])).encode()).decode().replace('\n', '') - request.add_header("Authorization", "Basic %s" % base64string) - data = "grant_type=client_credentials" - data_len = len(data) - request.add_header('Accept', 'application/json') - request.add_header('Content-Length', data_len) - request.data = data.encode(encoding='utf-8') + url = f'{self.api_url}{self.auth_url}' + + api_auth = HTTPBasicAuth(self.auth.client_id, self.auth.client_secret) + headers = {'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded'} + data = {'grant_type': 'client_credentials'} try: - if sys.version_info.major > 2 or (sys.version_info.major == 2 and sys.version_info.major > 7 or (sys.version_info.major == 7 and sys.version_info.major >= 9)): - response = urlopen(request, cafile=certifi.where()) - else: - response = urlopen(request) - except HTTPError as e: - logger = logging.getLogger(__name__) - fp = e.fp - body = fp.read() - fp.close() - if hasattr(e, 'code'): - logger.error("Paypal Error {0}({1}): {2}".format(e.code, e.msg, body)) - else: - logger.error("Paypal Error({0}): {1}".format(e.msg, body)) - else: - return json.loads(response.read().decode('utf-8'))['access_token'] + response = requests.post(url, headers=headers, data=data, auth=api_auth) + response.raise_for_status() # Raise an exception for HTTP errors + response_json = response.json() + return OAuthResponse(**response_json) + except requests.HTTPError as e: + raise PaypalAuthFailure(str(e), response=e.response) diff --git a/setup.py b/setup.py index 17d4cc9..05b04a8 100755 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def get_version(package): """ Return package version as listed in `__version__` in `init.py`. """ - init_py = open(os.path.join(package, '__init__.py')).read() + init_py = open(os.path.join(package, "__init__.py")).read() return re.match("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) @@ -19,9 +19,11 @@ def get_packages(package): """ Return root package and all sub-packages. """ - return [dirpath - for dirpath, dirnames, filenames in os.walk(package) - if os.path.exists(os.path.join(dirpath, '__init__.py'))] + return [ + dirpath + for dirpath, dirnames, filenames in os.walk(package) + if os.path.exists(os.path.join(dirpath, "__init__.py")) + ] def get_package_data(package): @@ -29,26 +31,29 @@ def get_package_data(package): Return all files under the root package, that are not in a package themselves. """ - walk = [(dirpath.replace(package + os.sep, '', 1), filenames) - for dirpath, dirnames, filenames in os.walk(package) - if 'tests' not in dirnames and not os.path.exists(os.path.join(dirpath, '__init__.py'))] + walk = [ + (dirpath.replace(package + os.sep, "", 1), filenames) + for dirpath, dirnames, filenames in os.walk(package) + if "tests" not in dirnames + and not os.path.exists(os.path.join(dirpath, "__init__.py")) + ] filepaths = [] for base, filenames in walk: - filepaths.extend([os.path.join(base, filename) - for filename in filenames]) + filepaths.extend([os.path.join(base, filename) for filename in filenames]) return {package: filepaths} + REQUIREMENTS = [ - 'Django>=1.8', - 'six>=1.10.0', - 'certifi>=2018.10.15', + "Django>=2.2", + "certifi>=2018.10.15", + "requests>=2.20.0", + "dataclass-wizard>=0.2.2", ] -version = get_version('django_paypal') - +version = get_version("django_paypal") -if sys.argv[-1] == 'publish': +if sys.argv[-1] == "publish": os.system("python setup.py sdist upload") print("You probably want to also tag the version now:") print(" git tag -a %s -m 'version %s'" % (version, version)) @@ -57,26 +62,28 @@ def get_package_data(package): setup( - name='django-paypal-plus', - author='Particulate Solutions GmbH', - author_email='tech@particulate.me', - description=u'Django integration of PayPal', + name="django-paypal-plus", + author="Particulate Solutions GmbH", + author_email="tech@particulate.me", + description="Django integration of PayPal's Orders v2 API", version=version, - url='https://github.com/ParticulateSolutions/django-paypal-plus', - packages=get_packages('django_paypal'), - package_data=get_package_data('django_paypal'), + url="https://github.com/ParticulateSolutions/django-paypal-plus", + packages=get_packages("django_paypal"), + package_data=get_package_data("django_paypal"), include_package_data=True, classifiers=[ - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Framework :: Django', - 'Topic :: Software Development :: Libraries :: Application Frameworks', - 'Topic :: Software Development :: Libraries :: Python Modules'], + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Framework :: Django", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Software Development :: Libraries :: Python Modules", + ], install_requires=REQUIREMENTS, - zip_safe=False) + zip_safe=False, +)