Source code for payments.views.admin

import csv
import datetime
import logging

import stripe
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db.models import Sum
from django.db.transaction import atomic
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render, redirect
from django.template.loader import render_to_string
from django.utils import timezone, dateformat
from django.utils.timezone import make_aware

from accounts.models import User
from cobalt.settings import (
    STRIPE_SECRET_KEY,
    GLOBAL_CURRENCY_SYMBOL,
    BRIDGE_CREDITS,
    COBALT_HOSTNAME,
    GLOBAL_ORG_ID,
    GLOBAL_ORG,
    ABF_ORG,
)
from logs.views import log_event
from masterpoints.views import user_summary
from notifications.views.core import contact_member
from organisations.models import Organisation
from payments.forms import (
    DateForm,
    StripeRefund,
    PaymentStaticForm,
    OrgStaticOverrideForm,
    SettlementForm,
    AdjustMemberForm,
    AdjustOrgForm,
)
from payments.models import (
    MemberTransaction,
    OrganisationTransaction,
    StripeTransaction,
    PaymentStatic,
    OrganisationSettlementFees,
)
from payments.views.core import (
    stripe_current_balance,
    get_balance,
    update_organisation,
    update_account,
    TZ,
    statement_common,
    org_balance,
)
from rbac.core import rbac_user_has_role
from rbac.decorators import rbac_check_role
from rbac.views import rbac_forbidden
from utils.utils import cobalt_paginator

logger = logging.getLogger("cobalt")


@rbac_check_role("payments.global.view")
def statement_admin_view(request, member_id):
    """Member statement view for administrators.

    Basic view of statement showing transactions in a web page. Used by an
    administrator to view a members statement

    Args:
        request - standard request object

    Returns:
        HTTPResponse

    """

    user = get_object_or_404(User, pk=member_id)
    (summary, club, balance, auto_button, events_list) = statement_common(user)

    things = cobalt_paginator(request, events_list)

    # See if this admin can process refunds
    refund_administrator = rbac_user_has_role(request.user, "payments.global.edit")

    return render(
        request,
        "payments/players/statement.html",
        {
            "things": things,
            "user": user,
            "summary": summary,
            "club": club,
            "balance": balance,
            "auto_button": auto_button,
            "auto_amount": user.auto_amount,
            "refund_administrator": refund_administrator,
            "admin_view": True,
        },
    )


@rbac_check_role("payments.global.view")
def statement_admin_summary(request):
    """Main statement page for system administrators

    Args:
        request (HTTPRequest): standard request object

    Returns:
        HTTPResponse
    """

    # Member summary
    total_members = User.objects.count()
    auto_top_up = User.objects.filter(stripe_auto_confirmed="On").count()

    members_list = MemberTransaction.objects.order_by(
        "member", "-created_date"
    ).distinct("member")

    # exclude zeros
    total_balance_members_list = []
    for member in members_list:
        if member.balance != 0:
            total_balance_members_list.append(member)

    total_balance_members = 0
    members_with_balances = 0
    for item in total_balance_members_list:
        total_balance_members += item.balance
        members_with_balances += 1

    # Organisation summary
    total_orgs = Organisation.objects.count()

    orgs_list = OrganisationTransaction.objects.order_by(
        "organisation", "-created_date"
    ).distinct("organisation")

    # exclude zeros
    total_balance_orgs_list = []
    for org in orgs_list:
        if org.balance != 0:
            total_balance_orgs_list.append(org)

    orgs_with_balances = 0
    total_balance_orgs = 0
    for item in total_balance_orgs_list:
        total_balance_orgs += item.balance
        orgs_with_balances += 1

    # Stripe Summary
    today = timezone.now()
    ref_date = today - datetime.timedelta(days=30)
    stripe = (
        StripeTransaction.objects.filter(created_date__gte=ref_date)
        .exclude(stripe_method=None)
        .aggregate(Sum("amount"))
    )

    stripe_balance = stripe_current_balance()

    return render(
        request,
        "payments/admin/statement_admin_summary.html",
        {
            "total_members": total_members,
            "auto_top_up": auto_top_up,
            "total_balance_members": total_balance_members,
            "total_orgs": total_orgs,
            "total_balance_orgs": total_balance_orgs,
            "members_with_balances": members_with_balances,
            "orgs_with_balances": orgs_with_balances,
            "balance": total_balance_orgs + total_balance_members,
            "stripe": stripe,
            "stripe_balance": stripe_balance,
        },
    )


@rbac_check_role("payments.global.view")
def stripe_pending(request):
    """Shows any pending stripe transactions.

    Stripe transactions should never really be in a pending state unless
    there is a problem. They go from intent to success usually. The only time
    they will sit in pending is if Stripe is slow to talk to us or there is an
    error.

    Args:
        request (HTTPRequest): standard request object

    Returns:
        HTTPResponse
    """

    # default message
    stripe_manual_pending_message = ""
    stripe_auto_pending_message = ""

    # Load data - latest stripe transaction
    stripe_latest = StripeTransaction.objects.filter(status="Succeeded").latest(
        "created_date"
    )

    # Anything with status of pending, will be a manual payment
    stripe_manual_pending = StripeTransaction.objects.filter(status="Pending")

    # First 20 with status Intent - could be an abandoned checkout, not an error
    stripe_manual_intent = StripeTransaction.objects.filter(status="Intent").order_by(
        "-created_date"
    )[:20]

    # Users in pending status for auto top up. Can happen from time to time. Usually only lasts a split second
    stripe_auto_pending = User.objects.filter(stripe_auto_confirmed="Pending")

    # Check if stripe manual pending events are real. There is an occasional issue where
    # the stripe transaction stays as pending even though the payment has been made. In this case
    # the member transaction will exist and we can ignore the problem.
    # Its a bit naughty for a report to change the data it is showing, but anyway...
    # TODO: Needs more debugging in the webhook call back
    for stripe_manual in stripe_manual_pending:
        if MemberTransaction.objects.filter(
            stripe_transaction=stripe_manual.id
        ).exists():
            message = f"Fixed stripe transaction with incorrect status. Changed from Pending to Succeeded. Stripe_trans: {stripe_manual.id}"

            log_event(
                user="Stripe API",
                severity="WARN",
                source="Payments",
                sub_source="pending_report",
                message=message,
            )

            logger.info(message)

            stripe_manual_pending_message = "Fixed some incorrect transactions"

            stripe_manual.status = "Succeeded"
            stripe_manual.save()

    # Check auto pending
    if stripe_auto_pending:
        # For auto payments, can can check with stripe about the status, we don't keep the time this happened on our side
        # It is possible to run this report just as the user is setting it up
        stripe.api_key = STRIPE_SECRET_KEY
        now = datetime.datetime.utcnow()

        for customer in stripe_auto_pending:
            try:
                # Get the last set up intent for this customer
                rc = stripe.SetupIntent.list(customer=customer.stripe_customer_id)
                created = rc["data"][0]["created"]
                last_attempt = datetime.datetime.utcfromtimestamp(created)
                ten_min = datetime.timedelta(minutes=10)

                if last_attempt + ten_min < now:
                    message = f"Stripe - auto top up set up failed, removing. {customer} - {customer.stripe_customer_id}"
                    log_event(
                        user="Stripe API",
                        severity="WARN",
                        source="Payments",
                        sub_source="pending_report",
                        message=message,
                    )
                    logger.warning(message)
                    customer.stripe_customer_id = None
                    customer.stripe_auto_confirmed = "Off"
                    customer.save()

                    stripe_auto_pending_message = "Users with expired data updated"

            except stripe.error.InvalidRequestError:
                message = f"Stripe - no such customer. Removing from system. {customer} - {customer.stripe_customer_id}"
                log_event(
                    user="Stripe API",
                    severity="WARN",
                    source="Payments",
                    sub_source="pending_report",
                    message=message,
                )
                logger.warning(message)
                customer.stripe_customer_id = None
                customer.stripe_auto_confirmed = "Off"
                customer.save()

                stripe_auto_pending_message = "Users with expired data updated"

    return render(
        request,
        "payments/admin/stripe_pending.html",
        {
            "stripe_manual_pending_message": stripe_manual_pending_message,
            "stripe_manual_pending": stripe_manual_pending,
            "stripe_manual_intent": stripe_manual_intent,
            "stripe_latest": stripe_latest,
            "stripe_auto_pending_message": stripe_auto_pending_message,
            "stripe_auto_pending": stripe_auto_pending,
        },
    )


[docs] @login_required() def admin_members_with_balance(request): """Shows any open balances held by members Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ if not rbac_user_has_role(request.user, "payments.global.view"): return rbac_forbidden(request, "payments.global.view") members_list = MemberTransaction.objects.order_by( "member", "-created_date" ).distinct("member") # exclude zeros members = [] for member in members_list: if member.balance != 0: members.append(member) things = cobalt_paginator(request, members) return render( request, "payments/admin/admin_members_with_balance.html", {"things": things} )
[docs] @login_required() def admin_orgs_with_balance(request): """Shows any open balances held by orgs Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ if not rbac_user_has_role(request.user, "payments.global.view"): return rbac_forbidden(request, "payments.global.view") orgs_list = OrganisationTransaction.objects.order_by( "organisation", "-created_date" ).distinct("organisation") # exclude zeros orgs = [] for org in orgs_list: if org.balance != 0: orgs.append(org) things = cobalt_paginator(request, orgs) return render( request, "payments/admin/admin_orgs_with_balance.html", {"things": things} )
[docs] @login_required() def admin_members_with_balance_csv(request): """Shows any open balances held by members - as CSV Args: request (HTTPRequest): standard request object Returns: HTTPResponse - CSV """ if not rbac_user_has_role(request.user, "payments.global.view"): return rbac_forbidden(request, "payments.global.view") members_list = MemberTransaction.objects.order_by( "member", "-created_date" ).distinct("member") # exclude zeros members = [] for member in members_list: if member.balance != 0: members.append(member) local_dt = timezone.localtime(timezone.now(), TZ) today = dateformat.format(local_dt, "Y-m-d H:i:s") response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="member-balances.csv"' writer = csv.writer(response) writer.writerow( ["Member Balances", "Downloaded by %s" % request.user.full_name, today] ) writer.writerow( ["Member Number", "Member First Name", "Member Last Name", "Balance"] ) for member in members: writer.writerow( [ member.member.system_number, member.member.first_name, member.member.last_name, member.balance, ] ) return response
[docs] @login_required() def admin_orgs_with_balance_csv(request): """Shows any open balances held by orgs - as CSV Args: request (HTTPRequest): standard request object Returns: HTTPResponse - CSV """ if not rbac_user_has_role(request.user, "payments.global.view"): return rbac_forbidden(request, "payments.global.view") orgs_list = OrganisationTransaction.objects.order_by( "organisation", "-created_date" ).distinct("organisation") # exclude zeros orgs = [] for org in orgs_list: if org.balance != 0: orgs.append(org) local_dt = timezone.localtime(timezone.now(), TZ) today = dateformat.format(local_dt, "Y-m-d H:i:s") response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="organisation-balances.csv"' writer = csv.writer(response) writer.writerow( ["Organisation Balances", "Downloaded by %s" % request.user.full_name, today] ) writer.writerow(["Club Number", "Club Name", "Balance"]) for org in orgs: writer.writerow([org.organisation.org_id, org.organisation.name, org.balance]) return response
def _admin_view_specific_transactions_csv_download( request, filename, title, manual_member, manual_org ): """Produce CSV file from the view of specific transaction types Args: request: Standard request Object filename: Name of output file title: Title for report manual_member: transactions relating to members (can be empty) manual_org: transactions relating to orgs (can be empty) """ local_dt = timezone.localtime(timezone.now(), TZ) today = dateformat.format(local_dt, "Y-m-d H:i:s") response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = f"attachment; filename={filename}" writer = csv.writer(response) writer.writerow( [ title, "Downloaded by %s" % request.user.full_name, today, ] ) # Members if manual_member: writer.writerow("") writer.writerow(["Member Transactions"]) writer.writerow( [ "Date", "Administrator", "Transaction Type", f"{GLOBAL_ORG} Number", "User", "Description", "Amount", ] ) for member in manual_member: local_dt = timezone.localtime(member.created_date, TZ) writer.writerow( [ dateformat.format(local_dt, "Y-m-d H:i:s"), member.other_member, member.type, member.member.system_number, member.member.full_name, member.description, member.amount, ] ) writer.writerow("") # Organisations if manual_org: writer.writerow("") writer.writerow(["Organisation Transactions"]) writer.writerow( [ "Date", "Other Organisation", "Administrator", "Transaction Type", "Club ID", "Organisation", "Description", "Amount", "Payment", "Fee (gross)", "GST", ] ) for org in manual_org: local_dt = timezone.localtime(org.created_date, TZ) gross_fee = -org.amount - org.bank_settlement_amount gst = round(float(gross_fee) * 1 / 11, 2) writer.writerow( [ dateformat.format(local_dt, "Y-m-d H:i:s"), org.other_organisation, org.member, org.type, org.organisation.org_id, org.organisation, org.description, org.amount, org.bank_settlement_amount, gross_fee, gst, ] ) return response
[docs] @login_required() def admin_view_specific_transactions(request, trans_type): """Shows transactions of a specific type. e.g. manual adjustments or settlements Args: request (HTTPRequest): standard request object trans_type (str): which transactions to show Returns: HTTPResponse (Can be CSV) """ transaction_types = { "manual_adjust": "Manual Adjustment", "settlement": "Settlement", } if not rbac_user_has_role(request.user, "payments.global.view"): return rbac_forbidden(request, "payments.global.view") if trans_type not in transaction_types: return HttpResponse(f"Invalid transaction type for this report: {trans_type}") form = DateForm(request.POST or None) if request.method == "POST" and form.is_valid(): # Need to make the dates TZ aware to_date_form = form.cleaned_data["to_date"] from_date_form = form.cleaned_data["from_date"] # date -> datetime to_date = datetime.datetime.combine(to_date_form, datetime.time(23, 59)) from_date = datetime.datetime.combine(from_date_form, datetime.time(0, 0)) # make aware to_date = make_aware(to_date, TZ) from_date = make_aware(from_date, TZ) manual_member = MemberTransaction.objects.filter( type=transaction_types[trans_type] ).filter(created_date__range=(from_date, to_date)) manual_org = OrganisationTransaction.objects.filter( type=transaction_types[trans_type] ).filter(created_date__range=(from_date, to_date)) if "export" not in request.POST: return render( request, "payments/admin/admin_view_specific_transactions.html", { "form": form, "manual_member": manual_member, "manual_org": manual_org, "title": transaction_types[trans_type], }, ) filename = transaction_types[trans_type].replace(" ", "_") + ".csv" return _admin_view_specific_transactions_csv_download( request, filename, transaction_types[trans_type], manual_member, manual_org ) return render( request, "payments/admin/admin_view_specific_transactions.html", {"form": form, "title": transaction_types[trans_type]}, )
@rbac_check_role("payments.global.view") def admin_view_stripe_transactions(request): """Shows stripe transactions for an admin Args: request (HTTPRequest): standard request object Returns: HTTPResponse (can be CSV) """ page_no = None form = DateForm(request.POST) if request.method == "POST" else DateForm() if form.is_valid(): # Need to make the dates TZ aware to_date_form = form.cleaned_data["to_date"] from_date_form = form.cleaned_data["from_date"] # date -> datetime to_date = datetime.datetime.combine(to_date_form, datetime.time(23, 59)) from_date = datetime.datetime.combine(from_date_form, datetime.time(0, 0)) # make aware to_date = make_aware(to_date, TZ) from_date = make_aware(from_date, TZ) stripes = ( StripeTransaction.objects.filter(created_date__range=(from_date, to_date)) .exclude(stripe_method=None) .order_by("-created_date") ) # Go to the first page if this is a new search page_no = 1 else: stripes = StripeTransaction.objects.exclude(stripe_method=None).order_by( "-created_date" ) # Get payment static pay_static = PaymentStatic.objects.filter(active=True).last() stripe.api_key = STRIPE_SECRET_KEY for stripe_item in stripes: stripe_item.amount_settle = ( float(stripe_item.amount) - float(pay_static.stripe_cost_per_transaction) ) * (1.0 - float(pay_static.stripe_percentage_charge) / 100.0) # We used to go to Stripe to get the details but it times out even if the list is quite small. if "export" in request.POST: local_dt = timezone.localtime(timezone.now(), TZ) today = dateformat.format(local_dt, "Y-m-d H:i:s") response = HttpResponse(content_type="text/csv") response[ "Content-Disposition" ] = 'attachment; filename="stripe-transactions.csv"' writer = csv.writer(response) writer.writerow( [ "Stripe Transactions", "Downloaded by %s" % request.user.full_name, today, ] ) writer.writerow( [ "Date", "Status", "member", "Amount", "Refund Amount", "Expected Settlement Amount", "Description", "stripe_reference", "stripe_exp_month", "stripe_exp_year", "stripe_last4", "linked_organisation", "linked_member", "linked_transaction_type", "linked_amount", "stripe_receipt_url", ] ) for stripe_item in stripes: local_dt = timezone.localtime(stripe_item.created_date, TZ) writer.writerow( [ dateformat.format(local_dt, "Y-m-d H:i:s"), stripe_item.status, stripe_item.member, stripe_item.amount, stripe_item.refund_amount, stripe_item.amount_settle, stripe_item.description, stripe_item.stripe_reference, stripe_item.stripe_exp_month, stripe_item.stripe_exp_year, stripe_item.stripe_last4, stripe_item.linked_organisation, stripe_item.linked_member, stripe_item.linked_transaction_type, stripe_item.linked_amount, stripe_item.stripe_receipt_url, ] ) return response else: things = cobalt_paginator(request, stripes, page_no=page_no) return render( request, "payments/admin/admin_view_stripe_transactions.html", {"form": form, "things": things}, ) @rbac_check_role("payments.global.view") def admin_view_stripe_transaction_detail(request, stripe_transaction_id): """Shows stripe transaction details for an admin Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ stripe_item = get_object_or_404(StripeTransaction, pk=stripe_transaction_id) payment_static = PaymentStatic.objects.filter(active="True").last() if not payment_static: return HttpResponse("<h1>Payment Static has not been set up</h1>") stripe.api_key = STRIPE_SECRET_KEY if stripe_item.stripe_balance_transaction: balance_tran = stripe.BalanceTransaction.retrieve( stripe_item.stripe_balance_transaction ) stripe_item.stripe_fees = balance_tran.fee / 100.0 stripe_item.stripe_fee_details = balance_tran.fee_details for row in stripe_item.stripe_fee_details: row.amount = row.amount / 100.0 stripe_item.stripe_settlement = balance_tran.net / 100.0 stripe_item.stripe_created_date = datetime.datetime.fromtimestamp( balance_tran.created ) stripe_item.stripe_available_on = datetime.datetime.fromtimestamp( balance_tran.available_on ) stripe_item.stripe_percentage_charge = ( 100.0 * (float(stripe_item.amount) - float(stripe_item.stripe_settlement)) / float(stripe_item.amount) ) our_estimate_fee = float(stripe_item.amount) * float( payment_static.stripe_percentage_charge ) / 100.0 + float(payment_static.stripe_cost_per_transaction) our_estimate_fee_percent = our_estimate_fee * 100.0 / float(stripe_item.amount) stripe_item.our_estimate_fee = "%.2f" % our_estimate_fee stripe_item.our_estimate_fee_percent = "%.2f" % our_estimate_fee_percent return render( request, "payments/admin/admin_view_stripe_transaction_detail.html", {"stripe_item": stripe_item}, ) @rbac_check_role("payments.global.edit") def admin_refund_stripe_transaction(request, stripe_transaction_id): """Allows an Admin to refund a Stripe transaction Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ stripe_item = get_object_or_404(StripeTransaction, pk=stripe_transaction_id) if stripe_item.member == request.user: messages.error( request, "You cannot refund your own transactions. Do it through Stripe, their security isn't as good as ours.", extra_tags="cobalt-message-error", ) return redirect("payments:admin_view_stripe_transactions") # Calculate how much refund is left stripe_item.refund_left = stripe_item.amount - stripe_item.refund_amount member_balance = get_balance(stripe_item.member) if request.method == "POST": form = StripeRefund(request.POST, payment_amount=stripe_item.refund_left) if form.is_valid(): # Check if this the first entry screen or the confirmation screen if "first-submit" in request.POST: # First screen so show user the confirm after_balance = float(member_balance) - float( form.cleaned_data["amount"] ) return render( request, "payments/admin/admin_refund_stripe_transaction_confirm.html", { "stripe_item": stripe_item, "form": form, "after_balance": after_balance, }, ) elif "confirm-submit" in request.POST: # Confirm screen so make refund amount = form.cleaned_data["amount"] description = form.cleaned_data["description"] # Stripe uses cents not dollars stripe_amount = int(amount * 100) stripe.api_key = STRIPE_SECRET_KEY try: rc = stripe.Refund.create( charge=stripe_item.stripe_reference, amount=stripe_amount, ) except stripe.error.InvalidRequestError as e: log_event( user=request.user, severity="CRITICAL", source="Payments", sub_source="Admin refund", message=str(e), ) return render( request, "payments/admin/payments_refund_error.html", {"rc": e, "stripe_item": stripe_item}, ) if rc["status"] not in ["succeeded", "pending"]: log_event( user=request.user, severity="CRITICAL", source="Payments", sub_source="Admin refund", message=f"Admin Refund. Unknown status from stripe refund. Stripe Item:{stripe_item} Return Code{rc}", ) return render( request, "payments/admin/payments_refund_error.html", {"rc": rc, "stripe_item": stripe_item}, ) # Call atomic database update refund_stripe_transaction_sub( stripe_item, amount, description, counterparty=request.user ) # Notify member email_body = f"""<b>{request.user.full_name}</b> has refunded {GLOBAL_CURRENCY_SYMBOL}{amount:.2f} to your card.<br><br> The description was: {description}<br><br> Please note that It can take up to two weeks for the money to appear in your card statement.<br><br> Your {BRIDGE_CREDITS} account balance has been reduced to reflect this refund.<br><br> You can view your statement by clicking on the link below<br><br> """ context = { "name": stripe_item.member.first_name, "title": "Card Refund", "email_body": email_body, "host": COBALT_HOSTNAME, "link": "/payments", "link_text": "View Statement", } html_msg = render_to_string( "notifications/email_with_button.html", context ) # send contact_member( member=stripe_item.member, msg="Card Refund - %s%s" % (GLOBAL_CURRENCY_SYMBOL, amount), contact_type="Email", html_msg=html_msg, link="/payments", subject="Card Refund", ) log_event( user=stripe_item.member, severity="INFO", source="Payments", sub_source="Admin refund", message=f"{request.user} refunded {GLOBAL_CURRENCY_SYMBOL}{amount} to {stripe_item.member.full_name}", ) msg = f"Refund Successful. Paid {GLOBAL_CURRENCY_SYMBOL}{amount} to {stripe_item.member}" messages.success(request, msg, extra_tags="cobalt-message-success") return redirect("payments:admin_view_stripe_transactions") else: form = StripeRefund(payment_amount=stripe_item.refund_left) form.fields["amount"].initial = stripe_item.refund_left form.fields["description"].initial = "Card Refund" return render( request, "payments/admin/admin_refund_stripe_transaction.html", {"stripe_item": stripe_item, "form": form, "member_balance": member_balance}, )
[docs] @atomic def refund_stripe_transaction_sub(stripe_item, amount, description, counterparty=None): """Atomic transaction update for refunds""" # Update the Stripe transaction if amount + stripe_item.refund_amount >= stripe_item.amount: stripe_item.status = "Refunded" else: stripe_item.status = "Partial refund" stripe_item.refund_amount += amount stripe_item.save() # Create a new transaction for the user balance = get_balance(stripe_item.member) - float(amount) abf = get_object_or_404(Organisation, pk=GLOBAL_ORG_ID) act = MemberTransaction() act.member = stripe_item.member act.amount = -amount # Linking to the stripe transaction messes up the statements # act.stripe_transaction = stripe_item act.balance = balance act.description = description act.organisation = abf act.type = "Card Refund" act.save() log_event( user=stripe_item.member, severity="INFO", source="Payments", sub_source="Card Refund", message=description, )
@rbac_check_role("payments.global.edit") def admin_payments_static(request): """Manage static data for payments Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ payment_static = PaymentStatic.objects.filter(active=True).last() if payment_static: form = PaymentStaticForm(instance=payment_static) else: form = PaymentStaticForm() if request.method == "POST": form = PaymentStaticForm(request.POST) if form.is_valid(): obj = form.save(commit=False) obj.modified_by = request.user obj.save() # set all others to be inactive PaymentStatic.objects.all().update(active=False) # set this one active payment_static = PaymentStatic.objects.order_by("id").last() payment_static.active = True payment_static.save() messages.success( request, "Settings updated", extra_tags="cobalt-message-success" ) return redirect("payments:statement_admin_summary") return render( request, "payments/admin/admin_payments_static.html", {"form": form, "payment_static_old": payment_static}, )
[docs] @login_required() def admin_payments_static_history(request): """history for static data for payments Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ if not rbac_user_has_role(request.user, "payments.global.edit"): return rbac_forbidden(request, "payments.global.edit") payment_statics = PaymentStatic.objects.order_by("-created_date") return render( request, "payments/admin/admin_payments_static_history.html", {"payment_statics": payment_statics}, )
[docs] @login_required() def admin_payments_static_org_override(request): """Manage static data for individual orgs (override default values) Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ if not rbac_user_has_role(request.user, "payments.global.edit"): return rbac_forbidden(request, "payments.global.edit") org_statics = OrganisationSettlementFees.objects.all() return render( request, "payments/admin/admin_payments_static_org_override.html", {"org_statics": org_statics}, )
[docs] @login_required() def admin_payments_static_org_override_add(request): """Manage static data for individual orgs (override default values) This screen adds an override Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ if not rbac_user_has_role(request.user, "payments.global.edit"): return rbac_forbidden(request, "payments.global.edit") form = OrgStaticOverrideForm(request.POST or None) if request.method == "POST": if form.is_valid(): form.save() messages.success( request, "Entry added", extra_tags="cobalt-message-success" ) return redirect("payments:admin_payments_static_org_override") else: messages.error(request, form.errors, extra_tags="cobalt-message-error") return render( request, "payments/admin/admin_payments_static_org_override_add.html", {"form": form}, )
@rbac_check_role("payments.global.edit") def admin_payments_static_org_override_delete(request, item_id): """Manage static data for individual orgs (override default values) This screen deletes an override Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ item = get_object_or_404(OrganisationSettlementFees, pk=item_id) item.delete() messages.success(request, "Entry deleted", extra_tags="cobalt-message-success") return redirect("payments:admin_payments_static_org_override") @rbac_check_role("payments.global.edit") def admin_player_payments(request, member_id): """Manage a players payments as an admin. E.g. make a refund to a credit card. Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ member = get_object_or_404(User, pk=member_id) summary = user_summary(member.system_number) balance = get_balance(member) stripes = StripeTransaction.objects.filter(member=member).order_by("-created_date")[ :10 ] return render( request, "payments/admin/admin_player_payments.html", {"profile": member, "summary": summary, "balance": balance, "stripes": stripes}, ) def _get_member_balance_at_date(ref_date): """Internal function to get list of members with balances at specific date""" # # get latest transaction per member - can't do a Sum after a distinct - not yet supported # members = ( # MemberTransaction.objects.filter(created_date__lt=ref_date) # .order_by("member", "-created_date") # .distinct("member") # .exclude(balance=0.0) # ) member_balances = ( MemberTransaction.objects.filter(created_date__lt=ref_date) .values( "member", "member__first_name", "member__last_name", "member__system_number", "balance", ) .distinct("member") .order_by("member", "-id") ) member_total_balance = 0.0 for member_balance in member_balances: member_total_balance += float(member_balance["balance"]) return member_total_balance, member_balances def _get_org_balance_at_date(ref_date): """Internal function to get list of organisations with balances at specific date""" # # get summary per organisation # org_trans = ( # OrganisationTransaction.objects.filter(created_date__lt=ref_date) # .values('organisation', "organisation__name", "type") # .order_by("organisation", "type") # .annotate(sub_amount=Sum('amount')) # ) # # print(org_trans) # for org in org_trans: # print(org) org_balances = ( OrganisationTransaction.objects.filter(created_date__lt=ref_date) .values("organisation", "organisation__name", "organisation__org_id", "balance") .distinct("organisation") .order_by("organisation", "-id") ) # Better to calculate total in Python as we already have the data loaded org_total_balance = 0.0 for org_balance_amt in org_balances: org_total_balance += float(org_balance_amt["balance"]) return org_total_balance, org_balances @rbac_check_role("payments.global.view") def admin_stripe_rec(request): """This will become the Stripe reconciliation. For now it just shows the balances to allow a manual reconciliation Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ ref_date, _ = _admin_stripe_rec_ref_date(request) members_balance, members = _get_member_balance_at_date(ref_date) orgs_balance, orgs = _get_org_balance_at_date(ref_date) return render( request, "payments/admin/stripe_rec.html", { "members_balance": members_balance, "orgs_balance": orgs_balance, "ref_date": ref_date, "members_count": members.count(), "orgs_count": orgs.count(), }, ) def _admin_stripe_rec_ref_date(request): """common function to handle reference date""" # Default date is last day of the previous month. Get first of this month and step back 1 day ref_date = datetime.datetime.now(tz=TZ).replace( day=1, hour=23, minute=59, second=59, microsecond=999_999 ) - datetime.timedelta(days=1) form_date = request.POST.get("ref_date") if form_date: ref_date = ( datetime.datetime.strptime(form_date, "%d/%m/%Y") .replace(tzinfo=TZ) .replace(hour=23, minute=59, second=59, microsecond=999_999) ) # also calculate date a month earlier ref_date_month_earlier = ref_date.replace( day=1, hour=0, minute=0, second=0, microsecond=0 ) - datetime.timedelta(days=1) return ref_date, ref_date_month_earlier @rbac_check_role("payments.global.view") def admin_stripe_rec_download(request): """CSV download of all movements for the month prior to the reference date Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ # Get the ref date ref_date, ref_date_month_earlier = _admin_stripe_rec_ref_date(request) # Get the 3 different kinds of financial transaction members = MemberTransaction.objects.filter(created_date__lte=ref_date).filter( created_date__gte=ref_date_month_earlier ) stripes = ( StripeTransaction.objects.filter(created_date__lte=ref_date) .filter(created_date__gte=ref_date_month_earlier) .filter(status__in=["Succeeded", "Partial refund", "Refunded"]) ) orgs = OrganisationTransaction.objects.filter(created_date__lte=ref_date).filter( created_date__gte=ref_date_month_earlier ) # Merge them together results = [] # MemberTransactions for member in members: counterparty = "" if member.other_member: counterparty = member.other_member if member.organisation: counterparty = member.organisation item = { "table": "Member Transaction", "created_date": member.created_date, "counterparty": counterparty, "reference_no": member.reference_no, "type": member.type, "description": member.description, "amount": member.amount, "balance": member.balance, } results.append(item) # OrgTransactions for org in orgs: counterparty = "" if org.member: counterparty = org.member item = { "table": "Organisation Transaction", "created_date": org.created_date, "counterparty": counterparty, "reference_no": org.reference_no, "type": org.type, "description": org.description, "amount": org.amount, "balance": org.balance, } results.append(item) # StripeTransactions for stripe_item in stripes: counterparty = "" if stripe_item.linked_member: counterparty = stripe_item.linked_member if stripe_item.linked_organisation: counterparty = stripe_item.linked_organisation item = { "table": "Stripe Transaction", "created_date": stripe_item.created_date, "counterparty": counterparty, "reference_no": stripe_item.stripe_reference, "type": stripe_item.status, "description": stripe_item.description, "amount": stripe_item.amount, "balance": "", } results.append(item) # Sort by created_date results = sorted(results, key=lambda k: k["created_date"]) local_dt = timezone.localtime(timezone.now(), TZ) today = dateformat.format(local_dt, "Y-m-d H:i:s") response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="reconciliation.csv"' writer = csv.writer(response) writer.writerow([f"Generated by {request.user.full_name}", f"Generated on {today}"]) writer.writerow( ["Date range >=", dateformat.format(ref_date_month_earlier, "Y-m-d H:i:s")] ) writer.writerow(["Date range <=", dateformat.format(ref_date, "Y-m-d H:i:s")]) writer.writerow([""]) writer.writerow( [ "Date", "Table", "Counterparty", "Reference", "Type", "Description", "Amount", "Balance", ] ) for result in results: writer.writerow( [ dateformat.format(result["created_date"], "Y-m-d H:i:s"), result["table"], result["counterparty"], result["reference_no"], result["type"], result["description"], result["amount"], result["balance"], ] ) return response @rbac_check_role("payments.global.view") def admin_stripe_rec_download_member(request): """CSV download of closing member balances just prior to the reference date Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ # Get the ref date ref_date, _ = _admin_stripe_rec_ref_date(request) # Get the member balances members_balance, members = _get_member_balance_at_date(ref_date) local_dt = timezone.localtime(timezone.now(), TZ) today = dateformat.format(local_dt, "Y-m-d H:i:s") response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="member_balances.csv"' writer = csv.writer(response) writer.writerow([f"Generated by {request.user.full_name}", f"Generated on {today}"]) writer.writerow(["Balances prior to", dateformat.format(ref_date, "Y-m-d H:i:s")]) writer.writerow([""]) writer.writerow( [ f"{GLOBAL_ORG} Number", "First Name", "Last Name", "Balance", ] ) for member in members: writer.writerow( [ member["member__system_number"], member["member__first_name"], member["member__last_name"], member["balance"], ] ) return response @rbac_check_role("payments.global.view") def admin_stripe_rec_download_org(request): """CSV download of closing organisation balances just prior to the reference date Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ # Get the ref date ref_date, _ = _admin_stripe_rec_ref_date(request) # Get the member balances org_balance, orgs = _get_org_balance_at_date(ref_date) local_dt = timezone.localtime(timezone.now(), TZ) today = dateformat.format(local_dt, "Y-m-d H:i:s") response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="organisation_balances.csv"' writer = csv.writer(response) writer.writerow([f"Generated by {request.user.full_name}", f"Generated on {today}"]) writer.writerow(["Balances prior to", dateformat.format(ref_date, "Y-m-d H:i:s")]) writer.writerow([""]) writer.writerow( [ "Organisation", f"{GLOBAL_ORG} Org Number", "Balance", ] ) for org in orgs: writer.writerow( [ org["organisation__name"], org["organisation__org_id"], org["balance"], ] ) return response
[docs] @login_required() @transaction.atomic def settlement(request): """process payments to organisations. This is expected to be a monthly activity. At certain points in time an administrator will clear out the balances of the organisations accounts and transfer actual money to them through the banking system. This is not currently possible to do electronically so this is a manual process. The administrator should use this list to match with the bank transactions and then confirm through this view that the payments have been made. Note: the club can set their own minimum_balance_after_settlement amount to maintain a float in their account. This is so they can still make outgoing payments before further incoming payments come in. Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ if not rbac_user_has_role(request.user, "payments.global.edit"): return rbac_forbidden(request, "payments.global.edit") payment_static = PaymentStatic.objects.filter(active="True").last() if not payment_static: return HttpResponse("<h1>Payment Static has not been set up</h1>") # orgs with outstanding balances # Django is a bit too clever here, so we actually have to include balance=0.0 and filter # it in the code, otherwise we get the most recent non-zero balance. There may be # a way to do this, but I couldn't figure it out. org_transactions = ( OrganisationTransaction.objects.order_by("organisation", "-created_date") .distinct("organisation") .select_related("organisation") ) org_list = [] non_zero_orgs = [] for org_transaction in org_transactions: # Take into account minimum_balance_after_settlement amount_to_settle = float(org_transaction.balance) - float( org_transaction.organisation.minimum_balance_after_settlement ) if amount_to_settle > 0.0: org_list.append((org_transaction.id, org_transaction.organisation.name)) # Add amount_to_settle field to org_transaction org_transaction.amount_to_settle = amount_to_settle non_zero_orgs.append(org_transaction) if request.method == "POST": form = SettlementForm(request.POST, orgs=org_list) if form.is_valid(): # load balances - Important! Do not get the current balance for an # org as this may have changed. Use the list confirmed by the user. settlement_ids = form.cleaned_data["settle_list"] settlements = OrganisationTransaction.objects.filter( pk__in=settlement_ids ).select_related("organisation") if "export" in request.POST: # CSV download local_dt = timezone.localtime(timezone.now(), TZ) today = dateformat.format(local_dt, "Y-m-d H:i:s") response = HttpResponse(content_type="text/csv") response[ "Content-Disposition" ] = 'attachment; filename="settlements.csv"' writer = csv.writer(response) writer.writerow( [ "Settlements Export", f"Downloaded by {request.user.full_name}", today, ] ) writer.writerow( [ "CLub Number", "CLub Name", "BSB", "Account Number", "Gross Amount", "Minimum Balance", "Net Amount", f"{GLOBAL_ORG} fees %", "Settlement Amount", ] ) for org_transaction in settlements: writer.writerow( [ org_transaction.organisation.org_id, org_transaction.organisation.name, org_transaction.organisation.bank_bsb, org_transaction.organisation.bank_account, org_transaction.balance, org_transaction.organisation.minimum_balance_after_settlement, org_transaction.balance - org_transaction.organisation.minimum_balance_after_settlement, org_transaction.organisation.settlement_fee_percent, org_transaction.settlement_amount, ] ) return response else: # confirm payments trans_list = [] total = 0.0 system_org = get_object_or_404(Organisation, pk=GLOBAL_ORG_ID) # Remove money from org accounts for item in settlements: amount_to_settle = float(item.balance) - float( item.organisation.minimum_balance_after_settlement ) total += amount_to_settle trans = update_organisation( organisation=item.organisation, other_organisation=system_org, amount=-amount_to_settle, description=f"Settlement from {GLOBAL_ORG}. Fees {item.organisation.settlement_fee_percent}%. Net Bank Transfer: {GLOBAL_CURRENCY_SYMBOL}{item.settlement_amount}.", payment_type="Settlement", bank_settlement_amount=item.settlement_amount, ) trans_list.append(trans) messages.success( request, "Settlement processed successfully.", extra_tags="cobalt-message-success", ) return render( request, "payments/admin/settlement-complete.html", {"trans": trans_list, "total": total}, ) else: form = SettlementForm(orgs=org_list) return render( request, "payments/admin/settlement.html", {"orgs": non_zero_orgs, "form": form} )
[docs] @login_required() def manual_adjust_member(request): """make a manual adjustment on a member account Args: request (HTTPRequest): standard request object Returns: HTTPResponse """ if not rbac_user_has_role(request.user, "payments.global.edit"): return rbac_forbidden(request, "payments.global.edit") if request.method == "POST": form = AdjustMemberForm(request.POST) if form.is_valid(): member = form.cleaned_data["member"] amount = form.cleaned_data["amount"] description = form.cleaned_data["description"] update_account( member=member, amount=amount, description=description, payment_type="Manual Adjustment", other_member=request.user, ) msg = "Manual adjustment successful. %s adjusted by %s%s" % ( member, GLOBAL_CURRENCY_SYMBOL, amount, ) messages.success(request, msg, extra_tags="cobalt-message-success") return redirect("payments:statement_admin_summary") else: form = AdjustMemberForm() return render( request, "payments/admin/manual_adjust_member.html", {"form": form} )
[docs] @login_required() def manual_adjust_org(request, org_id=None, default_transaction=None): """make a manual adjustment on an organisation account Args: request (HTTPRequest): standard request object org_id: optional id of organisation default_transaction: Allows a default for transaction type to be selected Returns: HTTPResponse """ if not rbac_user_has_role(request.user, "payments.global.edit"): return rbac_forbidden(request, "payments.global.edit") # TODO: move this to model as an enum payment_type_dic = {1: "Manual Adjustment", 2: "Settlement"} form = AdjustOrgForm(request.POST or None, default_transaction=default_transaction) if form.is_valid(): org = form.cleaned_data["organisation"] amount = form.cleaned_data["amount"] description = form.cleaned_data["description"] adjustment_type = int(form.cleaned_data["adjustment_type"]) payment_type = payment_type_dic[adjustment_type] # For settlements we mark this as being done by the ABF, but for anything else we put the actual user name on it if payment_type == "Settlement": member = None other_organisation = Organisation.objects.get(pk=ABF_ORG) bank_settlement_amount = -float(amount) * ( 1.0 - float(org.settlement_fee_percent) / 100.0 ) else: member = request.user other_organisation = None bank_settlement_amount = None update_organisation( organisation=org, amount=amount, description=description, payment_type=payment_type, member=member, other_organisation=other_organisation, bank_settlement_amount=bank_settlement_amount, ) msg = "Adjustment successful. %s adjusted by %s%s" % ( org, GLOBAL_CURRENCY_SYMBOL, amount, ) messages.success(request, msg, extra_tags="cobalt-message-success") return redirect("payments:statement_admin_summary") else: print(form.errors) org = get_object_or_404(Organisation, pk=org_id) if org_id else None balance = org_balance(org) return render( request, "payments/admin/manual_adjust_org.html", {"form": form, "org": org, "balance": balance}, )