diff --git a/artshow/admin.py b/artshow/admin.py index 99d9500..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, - 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") @@ -611,6 +618,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/cashier.py b/artshow/cashier.py index 07afde0..fa2c196 100644 --- a/artshow/cashier.py +++ b/artshow/cashier.py @@ -1,29 +1,41 @@ # 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 +from django.http import HttpResponse, HttpResponseBadRequest +from .models import ( + Bidder, Piece, InvoicePayment, InvoiceItem, Invoice, SquareInvoicePayment, + SquareTerminal +) 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 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 from django.utils.dateformat import DateFormat from django.views.decorators.clickjacking import xframe_options_sameorigin -import json +from django.views.decorators.http import ( + require_GET, require_POST, require_http_methods +) 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__) @@ -32,6 +44,7 @@ class BidderSearchForm (forms.Form): text = forms.CharField(label="Search Text") +@require_http_methods(['GET', 'POST']) @permission_required('artshow.add_invoice') def cashier(request): search_executed = False @@ -53,10 +66,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 @@ -69,16 +78,11 @@ def clean_amount(self): raise ValidationError("amount must be greater than 0") 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) + 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): @@ -87,6 +91,7 @@ class SelectPieceForm (forms.Form): # TODO probably need a @transaction.commit_on_success here +@require_http_methods(['GET', 'POST']) @permission_required('artshow.add_invoice') def cashier_bidder(request, bidder_id): @@ -95,79 +100,59 @@ 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, }) +@require_GET @permission_required('artshow.add_invoice') def cashier_bidder_invoices(request, bidder_id): @@ -180,6 +165,7 @@ def cashier_bidder_invoices(request, bidder_id): }) +@require_http_methods(['GET', 'POST']) @permission_required('artshow.add_invoice') def cashier_invoice(request, invoice_id): invoice = get_object_or_404(Invoice, pk=invoice_id) @@ -187,6 +173,47 @@ 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) + if payment.payment_method == 5: + 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() + json_items = [{ 'code': item.piece.code, 'name': item.piece.name, @@ -202,6 +229,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) @@ -217,20 +249,24 @@ 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, } 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': ALLOWED_PAYMENT_METHODS, }) +@require_GET @permission_required('artshow.add_invoice') @xframe_options_sameorigin def cashier_print_invoice(request, invoice_id): @@ -248,6 +284,29 @@ def cashier_print_invoice(request, invoice_id): }) +@require_GET +@permission_required('artshow.add_invoice') +def payment_status(request, payment_id): + payment = get_object_or_404(InvoicePayment, pk=payment_id) + + if payment.complete: + return HttpResponse('COMPLETE', content_type='text/plain') + else: + return HttpResponse('PENDING', content_type='text/plain') + + +@require_POST +@permission_required('artshow.add_invoice') +def payment_cancel(request, payment_id): + payment = get_object_or_404(SquareInvoicePayment, pk=payment_id) + + if payment.complete: + return HttpResponseBadRequest('Payment is complete') + + square.cancel_terminal_checkout(payment.checkout_id) + return HttpResponse() + + class PrintInvoiceForm (forms.Form): return_to = forms.CharField(required=False, widget=forms.HiddenInput) customer = forms.BooleanField(label="Customer", required=False) diff --git a/artshow/fixtures/testsquareterminals.json b/artshow/fixtures/testsquareterminals.json new file mode 100644 index 0000000..6bff894 --- /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" + } + } +] 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/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), + ), + ] diff --git a/artshow/models.py b/artshow/models.py index d680422..2b24d81 100644 --- a/artshow/models.py +++ b/artshow/models.py @@ -668,7 +668,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') @@ -676,6 +676,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') @@ -691,22 +694,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"), - # (4, "Other"), - ] - 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) @@ -771,6 +781,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/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 8092940..d19dd7b 100644 --- a/artshow/square.py +++ b/artshow/square.py @@ -9,21 +9,35 @@ 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 ( + SquareInvoicePayment, 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 log_errors(result): + assert result.is_error() + + for error in result.errors: + logger.error(f"Square error {error['category']}:{error['code']}: {error['detail']}") + + +def create_payment_url(artist, name, amount, redirect_url): + result = client().checkout.create_payment_link({ 'idempotency_key': str(uuid.uuid4()), 'quick_pay': { 'name': name, @@ -54,11 +68,66 @@ def create_payment_url(artist, name, amount, redirect_url): return payment.payment_link_url elif result.is_error(): - for error in result.errors: - logger.error(f"Square error {error['category']}:{error['code']}: {error['detail']}") + log_errors(result) + 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(): + log_errors(result) return None +def create_terminal_checkout(device_id, amount, reference_id, note): + result = 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, + }, + }, + 'payment_options': { + 'accept_partial_authorization': False, + }, + 'note': note, + }, + }) + + if result.is_success(): + return result.body['checkout']['id'] + + elif result.is_error(): + log_errors(result) + return None + + +def cancel_terminal_checkout(checkout_id): + result = client().terminal.cancel_terminal_checkout(checkout_id) + + if result.is_error(): + log_errors(result) + + def process_payment_created_or_updated(body): payment = body['data']['object']['payment'] @@ -83,9 +152,49 @@ 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_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_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..7a48abb 100644 --- a/artshow/templates/artshow/cashier_invoice.html +++ b/artshow/templates/artshow/cashier_invoice.html @@ -2,9 +2,21 @@ {% load static %} {% block title %}Invoice{% endblock %} {% block extra_head %} + + + {% endblock %} +{% block breadcrumbs %} + +{% endblock %} {% block content %}

Invoice

@@ -13,43 +25,203 @@

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 %} - + + + + + {% if not payment.complete %} + + {% endif %} + {% 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 }} + Pending... + +
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 %} + +{% 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.items %} + + {% 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 %} 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..b38ed87 --- /dev/null +++ b/artshow/templates/artshow/workflows_pair_terminal.html @@ -0,0 +1,44 @@ +{% extends "artshow/base_generic.html" %} +{% block breadcrumbs %} + +{% endblock %} +{% block content %} +

    Paired terminals (refresh):

    + + + + + + + {% for device in devices %} + + + + + + {% endfor %} +
    NameDevice Code
    {{ device.name }}{{ device.code }} + {% if device.pk == selected_device %} + Selected + {% else %} +
    + {% csrf_token %} + +
    + {% endif %} +
    +
    {% 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..1c93254 100644 --- a/artshow/urls.py +++ b/artshow/urls.py @@ -75,6 +75,9 @@ 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'^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/$', @@ -131,6 +134,10 @@ 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/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 f8213a1..2ec9dd4 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,35 @@ 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(), + '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)