From 13d20bdf9968366990812f261527980bfb0a045d Mon Sep 17 00:00:00 2001 From: binaryfox Date: Sat, 25 Nov 2023 19:08:59 -0800 Subject: [PATCH 01/11] Add workflow for pairing a Square terminal --- artshow/admin.py | 8 +++- artshow/migrations/0013_squareterminal.py | 22 +++++++++ artshow/models.py | 6 +++ artshow/square.py | 47 +++++++++++++++++-- .../templates/artshow/workflows_index.html | 1 + .../artshow/workflows_pair_terminal.html | 33 +++++++++++++ artshow/urls.py | 2 + artshow/workflows.py | 27 ++++++++++- 8 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 artshow/migrations/0013_squareterminal.py create mode 100644 artshow/templates/artshow/workflows_pair_terminal.html diff --git a/artshow/admin.py b/artshow/admin.py index 99d9500..721209d 100644 --- a/artshow/admin.py +++ b/artshow/admin.py @@ -27,7 +27,7 @@ Agent, Allocation, Artist, BatchScan, Bid, Bidder, BidderId, Checkoff, ChequePayment, EmailSignature, EmailTemplate, Invoice, InvoiceItem, InvoicePayment, Payment, PaymentType, Piece, Location, Space, SquarePayment, - SquareWebhook + SquareTerminal, SquareWebhook ) User = get_user_model() @@ -611,6 +611,12 @@ def clickable_artist(self, obj): readonly_fields = ('payment_link_id', 'payment_link_url', 'order_id') +@admin.register(SquareTerminal) +class SquareTerminalAdmin(admin.ModelAdmin): + list_display = ('name', 'device_id', 'code') + readonly_fields = ('device_id', 'code') + + @admin.register(SquareWebhook) class SquareWebhookAdmin(admin.ModelAdmin): list_display = ('webhook_event_id', 'timestamp', 'webhook_type', 'webhook_data_id') diff --git a/artshow/migrations/0013_squareterminal.py b/artshow/migrations/0013_squareterminal.py new file mode 100644 index 0000000..d6fe0bd --- /dev/null +++ b/artshow/migrations/0013_squareterminal.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.6 on 2023-11-26 02:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artshow', '0012_squarewebhook'), + ] + + operations = [ + migrations.CreateModel( + name='SquareTerminal', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('device_id', models.CharField(max_length=128)), + ('code', models.CharField(max_length=128)), + ('name', models.CharField(max_length=128)), + ], + ), + ] diff --git a/artshow/models.py b/artshow/models.py index f5622ec..2679b18 100644 --- a/artshow/models.py +++ b/artshow/models.py @@ -768,6 +768,12 @@ class Agent(models.Model): help_text="Person is allowed to make executive decisions regarding pieces") +class SquareTerminal(models.Model): + device_id = models.CharField(max_length=128) + code = models.CharField(max_length=128) + name = models.CharField(max_length=128) + + class SquareWebhook(models.Model): timestamp = models.DateTimeField() body = models.JSONField() diff --git a/artshow/square.py b/artshow/square.py index 8092940..540c14d 100644 --- a/artshow/square.py +++ b/artshow/square.py @@ -9,21 +9,26 @@ from django.utils.timezone import now from django.views.decorators.csrf import csrf_exempt +from functools import cache + from square.client import Client from square.utilities.webhooks_helper import is_valid_webhook_event_signature from .conf import settings -from .models import SquarePayment, SquareWebhook +from .models import SquarePayment, SquareTerminal, SquareWebhook logger = logging.getLogger(__name__) -def create_payment_url(artist, name, amount, redirect_url): - client = Client( +@cache +def client(): + return Client( access_token=settings.ARTSHOW_SQUARE_ACCESS_TOKEN, environment=settings.ARTSHOW_SQUARE_ENVIRONMENT) - result = client.checkout.create_payment_link({ + +def create_payment_url(artist, name, amount, redirect_url): + result = client().checkout.create_payment_link({ 'idempotency_key': str(uuid.uuid4()), 'quick_pay': { 'name': name, @@ -59,6 +64,25 @@ def create_payment_url(artist, name, amount, redirect_url): return None +def create_device_code(name): + result = client().devices.create_device_code({ + 'idempotency_key': str(uuid.uuid4()), + 'device_code': { + 'name': name, + 'location_id': settings.ARTSHOW_SQUARE_LOCATION_ID, + 'product_type': 'TERMINAL_API', + }, + }) + + if result.is_success(): + return result.body['device_code']['code'] + + elif result.is_error(): + for error in result.errors: + logger.error(f"Square error {error['category']}:{error['code']}: {error['detail']}") + return None + + def process_payment_created_or_updated(body): payment = body['data']['object']['payment'] @@ -83,9 +107,24 @@ def process_payment_created_or_updated(body): logger.info(f'Got webhook for unknown order: {order_id}') +def process_device_paired(body): + device_code = body['data']['object']['device_code'] + + if device_code['status'] != 'PAIRED': + return + + device = SquareTerminal() + device.device_id = device_code['device_id'] + device.code = device_code['code'] + device.name = device_code['name'] + device.save() + + def process_webhook(body): if body['type'] in ('payment.created', 'payment.updated'): process_payment_created_or_updated(body) + if body['type'] == 'device.code.paired': + process_device_paired(body) @csrf_exempt diff --git a/artshow/templates/artshow/workflows_index.html b/artshow/templates/artshow/workflows_index.html index a9e258b..9ef6cac 100644 --- a/artshow/templates/artshow/workflows_index.html +++ b/artshow/templates/artshow/workflows_index.html @@ -15,6 +15,7 @@
  • Bidder Check-in
  • Close Show
  • Print Cheques
  • +
  • Pair Square Terminal
  • Artist Check-out
  • {% endblock %} diff --git a/artshow/templates/artshow/workflows_pair_terminal.html b/artshow/templates/artshow/workflows_pair_terminal.html new file mode 100644 index 0000000..56eefa1 --- /dev/null +++ b/artshow/templates/artshow/workflows_pair_terminal.html @@ -0,0 +1,33 @@ +{% extends "artshow/base_generic.html" %} +{% block breadcrumbs %} + +{% endblock %} +{% block content %} +

    Paired terminals (refresh):

    + + + + + + {% for device in devices %} + + + + + {% endfor %} +
    NameDevice Code
    {{ device.name }}{{ device.code }}
    +
    {% csrf_token %} + {{ form.as_p }} + +
    +{% if device_code %} +

    + Log into the terminal with the following code: {{ device_code }} +

    +{% endif %} +{% endblock %} + diff --git a/artshow/urls.py b/artshow/urls.py index e8c738b..2d37c58 100644 --- a/artshow/urls.py +++ b/artshow/urls.py @@ -131,6 +131,8 @@ re_path(r'^workflows/printing/cheques/print/$', workflows.print_cheques_print, name='artshow-workflow-print-cheques-print'), + re_path(r'^workflows/pair_terminal/$', workflows.pair_terminal, + name='artshow-workflow-pair-terminal'), re_path(r'^workflows/artist_checkout/$', workflows.find_artist_checkout, name='artshow-workflow-artist-checkout-lookup'), re_path(r'^workflows/artist_checkout/(?P\d+)/$', diff --git a/artshow/workflows.py b/artshow/workflows.py index f8213a1..ee7c232 100644 --- a/artshow/workflows.py +++ b/artshow/workflows.py @@ -10,7 +10,10 @@ from .conf import settings from .mod11codes import make_check -from .models import Artist, BidderId, ChequePayment, Location, Piece, Space +from .models import ( + Artist, BidderId, ChequePayment, Location, Piece, Space, SquareTerminal +) +from . import square @permission_required('artshow.is_artshow_staff') @@ -530,3 +533,25 @@ def print_cheques_print(request): 'redirect': reverse('artshow-workflow-print-cheques'), } return render(request, 'artshow/cheque.html', c) + + +class PairTerminalForm(forms.Form): + name = forms.CharField(label='Name', max_length=128) + + +@permission_required('artshow.is_artshow_staff') +def pair_terminal(request): + device_code = None + if request.method == 'POST': + form = PairTerminalForm(request.POST) + if form.is_valid(): + device_code = square.create_device_code(form.cleaned_data['name']) + else: + form = PairTerminalForm() + + c = { + 'devices': SquareTerminal.objects.all(), + 'form': form, + 'device_code': device_code, + } + return render(request, 'artshow/workflows_pair_terminal.html', c) From 0f2aa52d11e88fce291ac172718770f6b4f4b9f4 Mon Sep 17 00:00:00 2001 From: binaryfox Date: Sat, 25 Nov 2023 19:09:12 -0800 Subject: [PATCH 02/11] Add basic create_terminal_checkout function --- artshow/square.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/artshow/square.py b/artshow/square.py index 540c14d..c0da19d 100644 --- a/artshow/square.py +++ b/artshow/square.py @@ -83,6 +83,27 @@ def create_device_code(name): return None +def create_terminal_checkout(device_id, amount, reference_id, note): + client().terminal.create_terminal_checkout({ + 'idempotency_key': str(uuid.uuid4()), + 'checkout': { + 'amount_money': { + 'amount': int(amount * 100), + 'currency': 'USD', + }, + 'reference_id': reference_id, + 'device_options': { + 'device_id': device_id, + 'skip_receipt_screen': True, + 'tip_settings': { + 'allow_tipping': False, + }, + }, + 'note': note, + }, + }) + + def process_payment_created_or_updated(body): payment = body['data']['object']['payment'] From 246d724544ca6ed245f2b679c42ff59c5b716708 Mon Sep 17 00:00:00 2001 From: binaryfox Date: Sun, 26 Nov 2023 19:09:47 -0800 Subject: [PATCH 03/11] Create invoice before collecting payments --- artshow/cashier.py | 93 ++++------ artshow/models.py | 3 + artshow/templates/artshow/cashier_bidder.html | 175 +----------------- .../artshow/cashier_bidder_invoices.html | 3 +- .../templates/artshow/cashier_invoice.html | 150 +++++++++++++-- 5 files changed, 182 insertions(+), 242 deletions(-) diff --git a/artshow/cashier.py b/artshow/cashier.py index 07afde0..2cbcd97 100644 --- a/artshow/cashier.py +++ b/artshow/cashier.py @@ -9,7 +9,6 @@ from django import forms from django.db.models import Q from django.forms import ModelForm -from django.forms.models import modelformset_factory, BaseModelFormSet from .conf import settings from django.core.exceptions import ValidationError from decimal import Decimal @@ -21,7 +20,6 @@ from django.utils import timezone from django.utils.dateformat import DateFormat from django.views.decorators.clickjacking import xframe_options_sameorigin -import json from .conf import _DISABLED as SETTING_DISABLED @@ -53,10 +51,6 @@ def cashier(request): return render(request, 'artshow/cashier.html', c) -class ItemsForm (forms.Form): - tax_paid = forms.DecimalField() - - class PaymentForm (ModelForm): class Meta: model = InvoicePayment @@ -70,17 +64,6 @@ def clean_amount(self): return amount -class PaymentFormSet (BaseModelFormSet): - def clean(self): - total = sum([form.cleaned_data['amount'] for form in self.forms], Decimal(0)) - # self.items_total is set from the cashier_bidder function. - if total != self.items_total: - raise ValidationError("payments (%s) must equal invoice total (%s)" % (total, self.items_total)) - - -PaymentFormSet = modelformset_factory(InvoicePayment, form=PaymentForm, formset=PaymentFormSet, extra=0) - - class SelectPieceForm (forms.Form): select = forms.BooleanField(required=False) @@ -95,76 +78,55 @@ def cashier_bidder(request, bidder_id): all_bids = bidder.top_bids(unsold_only=True) available_bids = [] pending_bids = [] - bid_dict = {} for bid in all_bids: if bid.piece.status == Piece.StatusWon: available_bids.append(bid) - bid_dict[bid.pk] = bid else: pending_bids.append(bid) + tax_rate = settings.ARTSHOW_TAX_RATE + error = None + if request.method == "POST": for bid in available_bids: form = SelectPieceForm(request.POST, prefix="bid-%d" % bid.pk) bid.form = form - items_form = ItemsForm(request.POST, prefix="items") - payment_formset = PaymentFormSet(request.POST, prefix="payment", queryset=InvoicePayment.objects.none()) - if all(bid.form.is_valid() for bid in available_bids) and items_form.is_valid(): + + if all(bid.form.is_valid() for bid in available_bids): logger.debug("Bids and Items Form passed") selected_bids = [bid for bid in available_bids if bid.form.cleaned_data['select']] if len(selected_bids) == 0: - items_form._errors['__all__'] = items_form.error_class(["Invoice must contain at least one item"]) - payment_formset.items_total = total = Decimal(0) + error = "Invoice must contain at least one item" else: subtotal = sum([bid.amount for bid in selected_bids], Decimal(0)) - tax_paid = items_form.cleaned_data['tax_paid'] - total = subtotal + tax_paid - - payment_formset.items_total = total - if payment_formset.is_valid(): - - logger.debug("payment formset passed") - - invoice = Invoice(payer=bidder, tax_paid=tax_paid, paid_date=timezone.now(), - created_by=request.user) - invoice.save() - payments = payment_formset.save(commit=False) - for payment in payments: - payment.invoice = invoice - payment.save() - for bid in selected_bids: - invoice_item = InvoiceItem(piece=bid.piece, price=bid.amount, invoice=invoice) - invoice_item.save() - bid.piece.status = Piece.StatusSold - bid.piece.save() - - return redirect(cashier_invoice, invoice_id=invoice.id) + tax_paid = subtotal * Decimal(tax_rate) + + invoice = Invoice(payer=bidder, tax_paid=tax_paid, paid_date=timezone.now(), + created_by=request.user) + invoice.save() + + for bid in selected_bids: + invoice_item = InvoiceItem(piece=bid.piece, price=bid.amount, invoice=invoice) + invoice_item.save() + bid.piece.status = Piece.StatusSold + bid.piece.save() + + return redirect(cashier_invoice, invoice_id=invoice.id) else: for bid in available_bids: form = SelectPieceForm(prefix="bid-%d" % bid.pk, initial={"select": False}) bid.form = form - items_form = ItemsForm(prefix="items") - payment_formset = PaymentFormSet(prefix="payment", queryset=InvoicePayment.objects.none()) - - payment_types = dict(InvoicePayment.PAYMENT_METHOD_CHOICES[1:]) - payment_types_json = json.dumps(payment_types, sort_keys=True) - - tax_rate = settings.ARTSHOW_TAX_RATE - money_precision = settings.ARTSHOW_MONEY_PRECISION return render(request, 'artshow/cashier_bidder.html', { 'bidder': bidder, 'available_bids': available_bids, 'pending_bids': pending_bids, - 'items_form': items_form, - 'payment_formset': payment_formset, - 'payment_types': payment_types, - 'payment_types_json': payment_types_json, 'tax_rate': tax_rate, - 'money_precision': money_precision, + 'money_precision': settings.ARTSHOW_MONEY_PRECISION, + 'error': error, }) @@ -187,6 +149,17 @@ def cashier_invoice(request, invoice_id): .filter(piece__reproduction_rights_included=True) \ .exists() + if request.method == "POST": + payment_form = PaymentForm(request.POST) + if payment_form.is_valid(): + payment = payment_form.save(commit=False) + payment.invoice = invoice + payment.save() + + return redirect(cashier_invoice, invoice_id=invoice.id) + else: + payment_form = PaymentForm() + json_items = [{ 'code': item.piece.code, 'name': item.piece.name, @@ -223,11 +196,13 @@ def cashier_invoice(request, invoice_id): return render(request, 'artshow/cashier_invoice.html', { 'invoice': invoice, + 'payment_form': payment_form, 'has_reproduction_rights': has_reproduction_rights, 'money_precision': settings.ARTSHOW_MONEY_PRECISION, 'tax_description': settings.ARTSHOW_TAX_DESCRIPTION, 'invoice_prefix': settings.ARTSHOW_INVOICE_PREFIX, 'json_data': json_data, + 'payment_types': InvoicePayment.PAYMENT_METHOD_CHOICES[1:], }) diff --git a/artshow/models.py b/artshow/models.py index 2679b18..c1bc02b 100644 --- a/artshow/models.py +++ b/artshow/models.py @@ -673,6 +673,9 @@ def item_total(self): def item_and_tax_total(self): return self.item_total() + (self.tax_paid or 0) + def payment_remaining(self): + return self.item_and_tax_total() - self.total_paid() + def invoiceitems(self): return self.invoiceitem_set.order_by('piece__location', 'piece') diff --git a/artshow/templates/artshow/cashier_bidder.html b/artshow/templates/artshow/cashier_bidder.html index aeeadfa..a4dd2cd 100644 --- a/artshow/templates/artshow/cashier_bidder.html +++ b/artshow/templates/artshow/cashier_bidder.html @@ -15,15 +15,6 @@ @@ -233,47 +103,14 @@

    Bidder Information

    {% empty %} No pieces to select from. {% endfor %} -{% if items_form.non_field_errors or items_form.tax_paid.errors %} -{{ items_form.non_field_errors }}{{ items_form.tax_paid.errors }} +{% if error %} +{{ error }} {% endif %} - Subtotal: — Tax: +Subtotal: — Tax: Total: -

    Payments

    -
    -{{ payment_formset.management_form }} - - - - -{% if payment_formset.non_form_errors %} - -{% endif %} -{% for f in payment_formset %} -{% if f.errors %} - -{% endif %} - -{% endfor %} - - - - - - - -
    TypeNotesAmount
    {{ payment_formset.non_form_errors }}
      {% for formerrors in f.errors %}{% for field in formerrors %}{% for error in field.errors %}
    • {{ error }}
    • {% endfor %}{% endfor %}{% endfor %}
    {{ f }}
    Total Tendered
    Remaining to Pay
    - - by -{% for pt_id, pt_name in payment_types.items %} -{% endfor %} -
    - -
    + diff --git a/artshow/templates/artshow/cashier_bidder_invoices.html b/artshow/templates/artshow/cashier_bidder_invoices.html index a091870..9da5177 100644 --- a/artshow/templates/artshow/cashier_bidder_invoices.html +++ b/artshow/templates/artshow/cashier_bidder_invoices.html @@ -3,7 +3,8 @@ {% endblock %} {% block content %} diff --git a/artshow/templates/artshow/cashier_invoice.html b/artshow/templates/artshow/cashier_invoice.html index 2b881a9..5cdf6d3 100644 --- a/artshow/templates/artshow/cashier_invoice.html +++ b/artshow/templates/artshow/cashier_invoice.html @@ -2,9 +2,20 @@ {% load static %} {% block title %}Invoice{% endblock %} {% block extra_head %} + + {% endblock %} +{% block breadcrumbs %} + +{% endblock %} {% block content %}

    Invoice

    @@ -13,43 +24,150 @@

    Invoice

    Invoicee{{ invoice.payer.name }}
    Bidder ID{{ invoice.payer.bidder_ids|pluralize }}{{ invoice.payer.bidder_ids|join:"," }}
    +

    Items

    {% for item in invoice.invoiceitem_set.all %} {% endfor %} - + - +
    ItemAmount
    {{ item.piece }}{{ item.price|floatformat:money_precision }}
    Subtotal{{ invoice.item_total|floatformat:money_precision }}
    Subtotal{{ invoice.item_total|floatformat:money_precision }}
    {{ tax_description }}{{ invoice.tax_paid|floatformat:money_precision }}
    Total with Tax{{ invoice.item_and_tax_total|floatformat:money_precision }}
    Total with Tax{{ invoice.item_and_tax_total|floatformat:money_precision }}
    + +{% if invoice.notes %} +

    Notes

    +

    {{ invoice.notes }}

    +{% endif %} +

    Payments

    +
    {% for payment in invoice.invoicepayment_set.all %} - + + + + + {% endfor %} - -
    TypeNotesAmount
    {{ payment.get_payment_method_display }}{{ payment.notes }}{{ payment.amount|floatformat:money_precision }}
    {{ payment.get_payment_method_display }}{{ payment.notes }}{{ payment.amount|floatformat:money_precision }}
    Payment Total{{ invoice.total_paid|floatformat:money_precision }}
    -{% if invoice.item_and_tax_total != invoice.total_paid %}

    Oh crap; item total and payment total do not match!

    + +{% if payment_form.non_form_errors %} + {{ payment_form.non_form_errors }} {% endif %} -{% if invoice.notes %} -

    Notes

    -

    {{ invoice.notes }}

    +{% if payment_form.errors %} +
      {% for formerrors in payment_form.errors %}{% for field in formerrors %}{% for error in field.errors %}
    • {{ error }}
    • {% endfor %}{% endfor %}{% endfor %}
    +{% endif %} + + Total Tendered + {{ invoice.total_paid|floatformat:money_precision }} + +{% if invoice.payment_remaining %} + + Remaining to Pay + {{ invoice.payment_remaining|floatformat:money_precision }} + + + + + by + {% for pt_id, pt_name in payment_types %} + + {% endfor %} + + {% endif %} + +
    +{% if invoice.payment_remaining %} +
    + {% csrf_token %} + {{ payment_form }} +
    +

    + Payment incomplete! +

    +{% else %}

    No receipt printer detected.

    +{% endif %} {{ json_data|json_script:"json-data" }} {% endblock %} From 1d48ce558b9d74ecddb0a577e2f076eb85fdfb37 Mon Sep 17 00:00:00 2001 From: binaryfox Date: Sun, 3 Dec 2023 17:43:42 -0800 Subject: [PATCH 04/11] Add test Square terminal device IDs --- artshow/fixtures/testsquareterminals.json | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 artshow/fixtures/testsquareterminals.json diff --git a/artshow/fixtures/testsquareterminals.json b/artshow/fixtures/testsquareterminals.json new file mode 100644 index 0000000..734dc6d --- /dev/null +++ b/artshow/fixtures/testsquareterminals.json @@ -0,0 +1,38 @@ +[ + { + "pk": 1, + "model": "artshow.squareterminal", + "fields": { + "name": "Approve up to $25", + "device_id": "9fa747a2-25ff-48ee-b078-04381f7c828f", + "code": "TEST1" + } + }, + { + "pk": 2, + "model": "artshow.squareterminal", + "fields": { + "name": "Cancel checkout", + "device_id": "841100b9-ee60-4537-9bcf-e30b2ba5e215", + "code": "TEST2" + } + }, + { + "pk": 3, + "model": "artshow.squareterminal", + "fields": { + "name": "Time out checkout", + "device_id": "0a956d49-619a-4530-8e5e-8eac603ffc5e", + "code": "TEST3" + } + }, + { + "pk": 4, + "model": "artshow.squareterminal", + "fields": { + "name": "Offline terminal", + "device_id": "da40d603-c2ea-4a65-8cfd-f42e36dab0c7", + "code": "TEST4" + } + } +] \ No newline at end of file From 1efca82e849229a9fe1e4fd6d683ee77b6957c17 Mon Sep 17 00:00:00 2001 From: binaryfox Date: Sun, 3 Dec 2023 18:23:48 -0800 Subject: [PATCH 05/11] Support selecting a Square Terminal --- .../templates/artshow/workflows_pair_terminal.html | 11 +++++++++++ artshow/urls.py | 2 ++ artshow/workflows.py | 10 ++++++++++ 3 files changed, 23 insertions(+) diff --git a/artshow/templates/artshow/workflows_pair_terminal.html b/artshow/templates/artshow/workflows_pair_terminal.html index 56eefa1..b38ed87 100644 --- a/artshow/templates/artshow/workflows_pair_terminal.html +++ b/artshow/templates/artshow/workflows_pair_terminal.html @@ -12,11 +12,22 @@ Name Device Code + {% for device in devices %} {{ device.name }} {{ device.code }} + + {% if device.pk == selected_device %} + Selected + {% else %} +
    + {% csrf_token %} + +
    + {% endif %} + {% endfor %} diff --git a/artshow/urls.py b/artshow/urls.py index 2d37c58..0e09d94 100644 --- a/artshow/urls.py +++ b/artshow/urls.py @@ -133,6 +133,8 @@ name='artshow-workflow-print-cheques-print'), re_path(r'^workflows/pair_terminal/$', workflows.pair_terminal, name='artshow-workflow-pair-terminal'), + re_path(r'^workflows/pair_terminal/select/(?P\d+)/$', + workflows.select_terminal, name='artshow-workflow-select-terminal'), re_path(r'^workflows/artist_checkout/$', workflows.find_artist_checkout, name='artshow-workflow-artist-checkout-lookup'), re_path(r'^workflows/artist_checkout/(?P\d+)/$', diff --git a/artshow/workflows.py b/artshow/workflows.py index ee7c232..2ec9dd4 100644 --- a/artshow/workflows.py +++ b/artshow/workflows.py @@ -551,7 +551,17 @@ def pair_terminal(request): c = { 'devices': SquareTerminal.objects.all(), + 'selected_device': request.session.get('terminal', default=None), 'form': form, 'device_code': device_code, } return render(request, 'artshow/workflows_pair_terminal.html', c) + + +@permission_required('artshow.is_artshow_staff') +@require_POST +def select_terminal(request, pk): + device = get_object_or_404(SquareTerminal, pk=pk) + request.session['terminal'] = device.pk + + return redirect(pair_terminal) From 54e0d4045d4a35d5928ec0fb3d0a81712071fbe9 Mon Sep 17 00:00:00 2001 From: binaryfox Date: Sun, 3 Dec 2023 18:41:55 -0800 Subject: [PATCH 06/11] Extremely basic Square terminal payment creation --- artshow/cashier.py | 15 ++++++++++++++- artshow/models.py | 3 ++- artshow/templates/artshow/cashier_invoice.html | 2 ++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/artshow/cashier.py b/artshow/cashier.py index 2cbcd97..2bb0ab0 100644 --- a/artshow/cashier.py +++ b/artshow/cashier.py @@ -5,7 +5,9 @@ import subprocess from django.shortcuts import render, get_object_or_404, redirect from django.http import HttpResponseBadRequest -from .models import Bidder, Piece, InvoicePayment, InvoiceItem, Invoice +from .models import ( + Bidder, Piece, InvoicePayment, InvoiceItem, Invoice, SquareTerminal +) from django import forms from django.db.models import Q from django.forms import ModelForm @@ -15,6 +17,7 @@ import logging from . import invoicegen from . import pdfreports +from . import square from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.utils import timezone @@ -153,6 +156,16 @@ def cashier_invoice(request, invoice_id): payment_form = PaymentForm(request.POST) if payment_form.is_valid(): payment = payment_form.save(commit=False) + if payment.payment_method == 5: + terminal = get_object_or_404(SquareTerminal, + pk=request.session.get('terminal')) + square.create_terminal_checkout( + terminal.device_id, + payment.amount, + f'{settings.ARTSHOW_INVOICE_PREFIX}{invoice.id}', + f'Art Show Bidder {",".join(invoice.payer.bidder_ids())}' + ) + payment.invoice = invoice payment.save() diff --git a/artshow/models.py b/artshow/models.py index c1bc02b..e085b73 100644 --- a/artshow/models.py +++ b/artshow/models.py @@ -699,8 +699,9 @@ class InvoicePayment(models.Model): (0, "Not Paid"), (1, "Cash"), # (2, "Check"), - (3, "Card"), + (3, "Card"), # Manually processed credit card transaction. # (4, "Other"), + (5, "Card"), # Credit card captured by Square Terminal. ] payment_method = models.IntegerField(choices=PAYMENT_METHOD_CHOICES, default=0) invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE) diff --git a/artshow/templates/artshow/cashier_invoice.html b/artshow/templates/artshow/cashier_invoice.html index 5cdf6d3..849c249 100644 --- a/artshow/templates/artshow/cashier_invoice.html +++ b/artshow/templates/artshow/cashier_invoice.html @@ -154,6 +154,8 @@

    Payments

    if (!notes) { return; } + } else if (paymenttype == 5 /* square */) { + // Payment is handled server-side. } else { return; } From ef8f5cf82ce30525bdc861292453d71fd0da0c28 Mon Sep 17 00:00:00 2001 From: binaryfox Date: Mon, 25 Dec 2023 12:28:09 -0800 Subject: [PATCH 07/11] Support making both Square and manual payments --- artshow/admin.py | 11 ++- artshow/cashier.py | 70 +++++++++++++++---- artshow/models.py | 30 ++++---- artshow/reports.py | 2 +- artshow/square.py | 42 ++++++++++- .../templates/artshow/cashier_invoice.html | 22 +++++- 6 files changed, 144 insertions(+), 33 deletions(-) diff --git a/artshow/admin.py b/artshow/admin.py index 721209d..d409906 100644 --- a/artshow/admin.py +++ b/artshow/admin.py @@ -26,8 +26,8 @@ from .models import ( Agent, Allocation, Artist, BatchScan, Bid, Bidder, BidderId, Checkoff, ChequePayment, EmailSignature, EmailTemplate, Invoice, InvoiceItem, - InvoicePayment, Payment, PaymentType, Piece, Location, Space, SquarePayment, - SquareTerminal, SquareWebhook + InvoicePayment, Payment, PaymentType, Piece, Location, Space, + SquareInvoicePayment, SquarePayment, SquareTerminal, SquareWebhook ) User = get_user_model() @@ -533,6 +533,13 @@ def num_pieces(self, obj): admin.site.register(Invoice, InvoiceAdmin) +@admin.register(SquareInvoicePayment) +class SquareInvoicePaymentAdmin(admin.ModelAdmin): + model = SquareInvoicePayment + fields = ('complete', 'amount', 'checkout_id', 'payment_ids') + readonly_fields = ('checkout_id', 'payment_ids') + + class BidAdmin(admin.ModelAdmin): raw_id_fields = ("bidder", "piece") diff --git a/artshow/cashier.py b/artshow/cashier.py index 2bb0ab0..0c98bfa 100644 --- a/artshow/cashier.py +++ b/artshow/cashier.py @@ -1,12 +1,14 @@ # Artshow Jockey # Copyright (C) 2009, 2010, 2011 Chris Cogdon # See file COPYING for licence details +from collections import OrderedDict from io import StringIO import subprocess from django.shortcuts import render, get_object_or_404, redirect from django.http import HttpResponseBadRequest from .models import ( - Bidder, Piece, InvoicePayment, InvoiceItem, Invoice, SquareTerminal + Bidder, Piece, InvoicePayment, InvoiceItem, Invoice, SquareInvoicePayment, + SquareTerminal ) from django import forms from django.db.models import Q @@ -25,6 +27,12 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin from .conf import _DISABLED as SETTING_DISABLED +ALLOWED_PAYMENT_METHODS = OrderedDict([ + (InvoicePayment.PaymentMethod.CASH, "Cash"), + (InvoicePayment.PaymentMethod.MANUAL_CARD, "Manual Card"), + (InvoicePayment.PaymentMethod.SQUARE_CARD, "Square Terminal"), +]) + logger = logging.getLogger(__name__) @@ -66,6 +74,12 @@ def clean_amount(self): raise ValidationError("amount must be greater than 0") return amount + def clean_payment_method(self): + payment_method = self.cleaned_data['payment_method'] + if payment_method not in ALLOWED_PAYMENT_METHODS: + raise ValidationError("payment method is not allowed") + return payment_method + class SelectPieceForm (forms.Form): select = forms.BooleanField(required=False) @@ -157,19 +171,39 @@ def cashier_invoice(request, invoice_id): if payment_form.is_valid(): payment = payment_form.save(commit=False) if payment.payment_method == 5: - terminal = get_object_or_404(SquareTerminal, - pk=request.session.get('terminal')) - square.create_terminal_checkout( - terminal.device_id, - payment.amount, - f'{settings.ARTSHOW_INVOICE_PREFIX}{invoice.id}', - f'Art Show Bidder {",".join(invoice.payer.bidder_ids())}' - ) - - payment.invoice = invoice - payment.save() - - return redirect(cashier_invoice, invoice_id=invoice.id) + try: + terminal = SquareTerminal.objects.get(pk=request.session.get('terminal')) + invoice_id = f'{settings.ARTSHOW_INVOICE_PREFIX}{invoice.id}' + note = f'{invoice_id} for Art Show Bidder {",".join(invoice.payer.bidder_ids())}' + result = square.create_terminal_checkout( + terminal.device_id, + payment.amount, + invoice_id, + note, + ) + if result is None: + payment_form.add_error(None, 'Failed to create Square checkout') + payment = None + else: + payment = SquareInvoicePayment( + amount=payment.amount, + payment_method=InvoicePayment.PaymentMethod.SQUARE_CARD, + notes=payment.notes, + checkout_id=result, + ) + + except SquareTerminal.DoesNotExist: + payment_form.add_error(None, 'No Square terminal selected') + payment = None + + else: + payment.complete = True + + if payment is not None: + payment.invoice = invoice + payment.save() + + return redirect(cashier_invoice, invoice_id=invoice.id) else: payment_form = PaymentForm() @@ -188,6 +222,11 @@ def cashier_invoice(request, invoice_id): 'amount': payment.amount, } for payment in invoice.invoicepayment_set.all()] + json_pending_payments = [ + payment.pk + for payment in invoice.invoicepayment_set.filter(complete=False) + ] + invoice_date = invoice.paid_date.astimezone(timezone.get_current_timezone()) formatted_date = DateFormat(invoice_date).format(settings.DATETIME_FORMAT) @@ -203,6 +242,7 @@ def cashier_invoice(request, invoice_id): 'taxPaid': invoice.tax_paid, 'totalPaid': invoice.total_paid(), 'payments': json_payments, + 'pendingPayments': json_pending_payments, 'moneyPrecision': settings.ARTSHOW_MONEY_PRECISION, 'taxDescription': settings.ARTSHOW_TAX_DESCRIPTION, } @@ -215,7 +255,7 @@ def cashier_invoice(request, invoice_id): 'tax_description': settings.ARTSHOW_TAX_DESCRIPTION, 'invoice_prefix': settings.ARTSHOW_INVOICE_PREFIX, 'json_data': json_data, - 'payment_types': InvoicePayment.PAYMENT_METHOD_CHOICES[1:], + 'payment_types': ALLOWED_PAYMENT_METHODS, }) diff --git a/artshow/models.py b/artshow/models.py index e085b73..94a3dc5 100644 --- a/artshow/models.py +++ b/artshow/models.py @@ -665,7 +665,7 @@ class Invoice (models.Model): tax_paid = models.DecimalField(max_digits=7, decimal_places=2, blank=True, null=True) def total_paid(self): - return self.invoicepayment_set.aggregate(sum=Sum('amount'))['sum'] or Decimal('0.0') + return self.invoicepayment_set.filter(complete=True).aggregate(sum=Sum('amount'))['sum'] or Decimal('0.0') def item_total(self): return self.invoiceitem_set.aggregate(sum=Sum('price'))['sum'] or Decimal('0.0') @@ -691,23 +691,29 @@ def get_absolute_url(self): class InvoicePayment(models.Model): - amount = models.DecimalField(max_digits=7, decimal_places=2) # The cashier code expects this ordering and numbering to special-case each of the payment # types. - # TODO: Create a flexible table with flags indicating how each should be handled. - PAYMENT_METHOD_CHOICES = [ - (0, "Not Paid"), - (1, "Cash"), - # (2, "Check"), - (3, "Card"), # Manually processed credit card transaction. - # (4, "Other"), - (5, "Card"), # Credit card captured by Square Terminal. - ] - payment_method = models.IntegerField(choices=PAYMENT_METHOD_CHOICES, default=0) + class PaymentMethod(models.IntegerChoices): + NOT_PAID = 0, "Not Paid" + CASH = 1, "Cash" + CHECK = 2, "Check" + MANUAL_CARD = 3, "Card" # Manually processed credit card transaction. + OTHER = 4, "Other" + SQUARE_CARD = 5, "Card" # Credit card captured by Square Terminal. + + complete = models.BooleanField(default=False) + amount = models.DecimalField(max_digits=7, decimal_places=2) + payment_method = models.IntegerField(choices=PaymentMethod.choices, + default=PaymentMethod.NOT_PAID) invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE) notes = models.CharField(max_length=100, blank=True) +class SquareInvoicePayment (InvoicePayment): + checkout_id = models.CharField(max_length=255) + payment_ids = models.TextField(blank=True) + + class InvoiceItem (models.Model): piece = models.OneToOneField(Piece, on_delete=models.CASCADE) invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE) diff --git a/artshow/reports.py b/artshow/reports.py index a9c9393..8070368 100644 --- a/artshow/reports.py +++ b/artshow/reports.py @@ -232,7 +232,7 @@ def get_summary_statistics(): total_charges = tax_paid + piece_charges invoice_payments = InvoicePayment.objects.values('payment_method').annotate(total=Sum('amount')) - payment_method_choice_dict = dict(InvoicePayment.PAYMENT_METHOD_CHOICES) + payment_method_choice_dict = dict(InvoicePayment.PaymentMethod.choices) total_invoice_payments = Decimal(0) for ip in invoice_payments: ip['payment_method_desc'] = payment_method_choice_dict[ip['payment_method']] diff --git a/artshow/square.py b/artshow/square.py index c0da19d..c3fe07a 100644 --- a/artshow/square.py +++ b/artshow/square.py @@ -15,7 +15,9 @@ from square.utilities.webhooks_helper import is_valid_webhook_event_signature from .conf import settings -from .models import SquarePayment, SquareTerminal, SquareWebhook +from .models import ( + SquareInvoicePayment, SquarePayment, SquareTerminal, SquareWebhook +) logger = logging.getLogger(__name__) @@ -84,7 +86,7 @@ def create_device_code(name): def create_terminal_checkout(device_id, amount, reference_id, note): - client().terminal.create_terminal_checkout({ + result = client().terminal.create_terminal_checkout({ 'idempotency_key': str(uuid.uuid4()), 'checkout': { 'amount_money': { @@ -99,10 +101,21 @@ def create_terminal_checkout(device_id, amount, reference_id, note): 'allow_tipping': False, }, }, + 'payment_options': { + 'accept_partial_authorization': False, + }, 'note': note, }, }) + if result.is_success(): + return result.body['checkout']['id'] + + elif result.is_error(): + for error in result.errors: + logger.error(f"Square error {error['category']}:{error['code']}: {error['detail']}") + return None + def process_payment_created_or_updated(body): payment = body['data']['object']['payment'] @@ -141,11 +154,36 @@ def process_device_paired(body): device.save() +def process_checkout_created_or_updated(body): + checkout = body['data']['object']['checkout'] + + try: + checkout_id = checkout['id'] + payment = SquareInvoicePayment.objects.get(checkout_id=checkout_id) + except SquareInvoicePayment.DoesNotExist: + logger.info(f'Got webhook for unknown checkout: {checkout_id}') + + currency = checkout['amount_money']['currency'] + if currency != 'USD': + raise Exception(f'Unexpected currency: {currency}') + + checkout_status = checkout['status'] + if checkout_status == 'CANCELED': + payment.delete() + elif checkout_status == 'COMPLETED': + payment.amount = Decimal(checkout['amount_money']['amount'] / 100) + payment.payment_ids = ', '.join(checkout['payment_ids']) + payment.complete = True + payment.save() + + def process_webhook(body): if body['type'] in ('payment.created', 'payment.updated'): process_payment_created_or_updated(body) if body['type'] == 'device.code.paired': process_device_paired(body) + if body['type'] in ('terminal.checkout.created', 'terminal.checkout.updated'): + process_checkout_created_or_updated(body) @csrf_exempt diff --git a/artshow/templates/artshow/cashier_invoice.html b/artshow/templates/artshow/cashier_invoice.html index 849c249..c3a8f3a 100644 --- a/artshow/templates/artshow/cashier_invoice.html +++ b/artshow/templates/artshow/cashier_invoice.html @@ -50,6 +50,9 @@

    Payments

    {{ payment.get_payment_method_display }} {{ payment.notes }} {{ payment.amount|floatformat:money_precision }} + {% if not payment.complete %} + Pending... + {% endif %} {% endfor %} @@ -74,7 +77,7 @@

    Payments

    Pay: by - {% for pt_id, pt_name in payment_types %} + {% for pt_id, pt_name in payment_types.items %} {% endfor %} @@ -103,6 +106,23 @@

    Payments

    + {% endblock %} {% block breadcrumbs %} @@ -107,11 +108,22 @@

    Payments

    const json = JSON.parse(document.getElementById('json-data').textContent); async function checkPendingPayments() { - for (payment in json.pendingPayments) { - const response = await fetch(`/artshow/cashier/payment/{payment}`); + for (payment of json.pendingPayments) { + const response = await fetch(`/artshow/cashier/payment/${payment}/`, { + credentials: 'include', + mode: 'cors', + headers: { + 'Accept': 'text/plain', + 'X-CSRFToken': Cookies.get('csrftoken'), + } + }); + if (!response.ok) { + window.location.reload(); + return; + } const status = await response.text(); - if (status == 'COMPLETE') { - window.reload(); + if (status != 'PENDING') { + window.location.reload(); return; } } diff --git a/artshow/urls.py b/artshow/urls.py index 0e09d94..a701a52 100644 --- a/artshow/urls.py +++ b/artshow/urls.py @@ -75,6 +75,7 @@ pdfreports.pdf_invoice), re_path(r'^cashier/invoice/(?P\d+)/picklist/$', pdfreports.pdf_picklist), + re_path(r'^cashier/payment/(?P\d+)/$', cashier.payment_status), re_path(r'^reports/winning-bidders-pdf/$', pdfreports.winning_bidders, name='artshow-winning-bidders-pdf'), re_path(r'^reports/bid-entry-by-location-pdf/$', From 998ef6aaa90b8237ee1b07083f89d922d6e47f70 Mon Sep 17 00:00:00 2001 From: binaryfox Date: Tue, 26 Dec 2023 17:24:30 -0800 Subject: [PATCH 09/11] Support canceling Square payments --- artshow/cashier.py | 2 +- artshow/square.py | 2 +- .../templates/artshow/cashier_invoice.html | 22 ++++++++++++++++++- artshow/urls.py | 2 ++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/artshow/cashier.py b/artshow/cashier.py index 947882e..fa2c196 100644 --- a/artshow/cashier.py +++ b/artshow/cashier.py @@ -300,7 +300,7 @@ def payment_status(request, payment_id): def payment_cancel(request, payment_id): payment = get_object_or_404(SquareInvoicePayment, pk=payment_id) - if not payment.complete: + if payment.complete: return HttpResponseBadRequest('Payment is complete') square.cancel_terminal_checkout(payment.checkout_id) diff --git a/artshow/square.py b/artshow/square.py index 99f81f1..d19dd7b 100644 --- a/artshow/square.py +++ b/artshow/square.py @@ -122,7 +122,7 @@ def create_terminal_checkout(device_id, amount, reference_id, note): def cancel_terminal_checkout(checkout_id): - result = client.terminal.cancel_terminal_checkout(checkout_id) + result = client().terminal.cancel_terminal_checkout(checkout_id) if result.is_error(): log_errors(result) diff --git a/artshow/templates/artshow/cashier_invoice.html b/artshow/templates/artshow/cashier_invoice.html index 2c47d15..7a48abb 100644 --- a/artshow/templates/artshow/cashier_invoice.html +++ b/artshow/templates/artshow/cashier_invoice.html @@ -52,7 +52,10 @@

    Payments

    {{ payment.notes }} {{ payment.amount|floatformat:money_precision }} {% if not payment.complete %} - Pending... + + Pending... + + {% endif %} {% endfor %} @@ -199,6 +202,23 @@

    Payments

    paymentform.find("[name=amount]").val(amount); paymentform.submit(); }); + + paymenttable.find('.cancelpaymentbutton').click(function(event) { + event.preventDefault(); + + this.disabled = true; + + const payment = $(this).attr('payment'); + fetch(`/artshow/cashier/payment/${payment}/cancel/`, { + method: 'POST', + credentials: 'include', + mode: 'cors', + headers: { + 'Accept': 'text/plain', + 'X-CSRFToken': Cookies.get('csrftoken'), + } + }); + }); }); }); diff --git a/artshow/urls.py b/artshow/urls.py index a701a52..1c93254 100644 --- a/artshow/urls.py +++ b/artshow/urls.py @@ -76,6 +76,8 @@ re_path(r'^cashier/invoice/(?P\d+)/picklist/$', pdfreports.pdf_picklist), re_path(r'^cashier/payment/(?P\d+)/$', cashier.payment_status), + re_path(r'^cashier/payment/(?P\d+)/cancel/$', + cashier.payment_cancel, name='artshow-cashier-payment-cancel'), re_path(r'^reports/winning-bidders-pdf/$', pdfreports.winning_bidders, name='artshow-winning-bidders-pdf'), re_path(r'^reports/bid-entry-by-location-pdf/$', From 34c4851cdc85fe9c48362c6b5bfb59afd828facd Mon Sep 17 00:00:00 2001 From: binaryfox Date: Wed, 27 Dec 2023 21:18:40 -0800 Subject: [PATCH 10/11] Add missing migration --- ...ayment_invoicepayment_complete_and_more.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 artshow/migrations/0014_squareinvoicepayment_invoicepayment_complete_and_more.py diff --git a/artshow/migrations/0014_squareinvoicepayment_invoicepayment_complete_and_more.py b/artshow/migrations/0014_squareinvoicepayment_invoicepayment_complete_and_more.py new file mode 100644 index 0000000..607693b --- /dev/null +++ b/artshow/migrations/0014_squareinvoicepayment_invoicepayment_complete_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.7 on 2023-12-11 03:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('artshow', '0013_squareterminal'), + ] + + operations = [ + migrations.CreateModel( + name='SquareInvoicePayment', + fields=[ + ('invoicepayment_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='artshow.invoicepayment')), + ('checkout_id', models.CharField(max_length=255)), + ('payment_ids', models.TextField(blank=True)), + ], + bases=('artshow.invoicepayment',), + ), + migrations.AddField( + model_name='invoicepayment', + name='complete', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='invoicepayment', + name='payment_method', + field=models.IntegerField(choices=[(0, 'Not Paid'), (1, 'Cash'), (2, 'Check'), (3, 'Card'), (4, 'Other'), (5, 'Card')], default=0), + ), + ] From 9823b455d8a0fa9135fa90502a3b01f973356d49 Mon Sep 17 00:00:00 2001 From: binaryfox Date: Tue, 2 Jan 2024 17:20:30 -0800 Subject: [PATCH 11/11] Add newline at end of testsquareterminals.json --- artshow/fixtures/testsquareterminals.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artshow/fixtures/testsquareterminals.json b/artshow/fixtures/testsquareterminals.json index 734dc6d..6bff894 100644 --- a/artshow/fixtures/testsquareterminals.json +++ b/artshow/fixtures/testsquareterminals.json @@ -35,4 +35,4 @@ "code": "TEST4" } } -] \ No newline at end of file +]