# -*- coding: utf-8 -*-
"""Handles all activities associated with payments that do not talk to users.
This module handles all of the functions that do not interact directly with
a user. i.e. they do not generally accept a ``Request`` and return an
``HttpResponse``. Arguably these could have been put directly into models.py
but it seems cleaner to store them here.
See also `Payments Views`_. This handles the user side of the interactions.
They both work together.
Key Points:
- Payments is a service module, it is requested to do things on behalf of
another module and does not know why it is doing them.
- Payments are often not real time, for manual payments, the user will
be taken to another screen that interacts directly with Stripe, and for
automatic top up payments, the top up may fail and require user input.
- The asynchronous nature of payments makes it more complex than many of
the Cobalt modules so the documentation needs to be of a higher standard.
See `Payments Overview`_ for more details.
.. _Payments Views:
#module-payments.views
.. _Payments Overview:
./payments_overview.html
"""
# JPG TESTING - for COB-804 race condition testing
import time
import json
import logging
from decimal import Decimal
from json import JSONDecodeError
import datetime
import pytz
import requests
import stripe
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Sum, F
from django.http import HttpResponse, JsonResponse
from django.template.loader import get_template
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from accounts.models import User
from cobalt.settings import (
STRIPE_SECRET_KEY,
STRIPE_PUBLISHABLE_KEY,
AUTO_TOP_UP_LOW_LIMIT,
BRIDGE_CREDITS,
GLOBAL_CURRENCY_SYMBOL,
TIME_ZONE,
GLOBAL_MPSERVER,
GLOBAL_TITLE,
COBALT_HOSTNAME,
)
import events.views.core as events_core
from logs.views import log_event
from notifications.views.core import contact_member, send_cobalt_email_with_template
from payments.models import (
StripeTransaction,
MemberTransaction,
OrganisationTransaction,
StripeLog,
UserPendingPayment,
PaymentStatic,
)
from payments.views.payments_api import notify_member_to_member_transfer
TZ = pytz.timezone(TIME_ZONE)
logger = logging.getLogger("cobalt")
#######################
# get_balance_detail #
#######################
[docs]
def get_balance_detail(member):
"""Called by dashboard to show basic information
Args:
member: A User object - the member whose balance is required
Returns:
dict: Keys - balance and last_top_up
"""
last_tran = (
MemberTransaction.objects.filter(member=member).order_by("created_date").last()
)
if last_tran:
return {
"balance": last_tran.balance,
"balance_num": last_tran.balance,
"last_top_up": last_tran.created_date,
}
else:
return {"balance": "0", "balance_num": None, "last_top_up": None}
################
# get_balance #
################
[docs]
def get_balance(member):
"""Gets member account balance
This function returns the current balance of the member's account.
Args:
member (User): A User object
Returns:
float: The member's current balance
"""
# JPG - needs to take an update lock !!
last_tran = (
MemberTransaction.objects.filter(member=member).order_by("created_date").last()
)
return float(last_tran.balance) if last_tran else 0.0
###############################################
# get_balance and recent transactions for org #
###############################################
[docs]
def get_balance_and_recent_trans_org(org):
"""Gets organisation account balance and most recent transactions
This function returns the current balance of the organisation's account and the most recent transactions.
Args:
org (organisations.models.Organisation): An Organisation object
Returns:
float: The member's current balance
list: the most recent transactions
"""
trans = OrganisationTransaction.objects.filter(organisation=org).order_by("-pk")[
:20
]
last_tran = trans.first()
balance = float(last_tran.balance) if last_tran else 0.0
return balance, trans
#############################
# get_user_pending_payments #
#############################
[docs]
def get_user_pending_payments(system_number):
"""Get any IOUs for this user. Called by the dashboard"""
return UserPendingPayment.objects.filter(system_number=system_number)
################################
# stripe_manual_payment_intent #
################################
[docs]
@login_required()
def stripe_manual_payment_intent(request):
"""Called from the checkout webpage.
When a user is going to pay with a credit card we
tell Stripe and Stripe gets ready for it. By this point in the process
we have handed over control to the Stripe code which calls this function
over Ajax.
This functions expects a json payload as part of `request`.
Args:
request - This needs to contain a Json payload.
Notes:
The Json should include:
data{"id": This is the StripeTransaction in our table that we are handling
"amount": The amount in the system currency}
Returns:
json: {'publishableKey':? 'clientSecret':?}
Notes:
publishableKey = our Public Stripe key,
clientSecret = client secret from Stripe
"""
if request.method != "POST":
return JsonResponse({"error": "POST required"})
data = json.loads(request.body)
# check data - do not trust it
try:
payload_cents = int(float(data["amount"]) * 100.0)
payload_cobalt_pay_id = data["id"]
except KeyError:
log_event(
request=request,
user=request.user,
severity="ERROR",
source="Payments",
sub_source="stripe_manual_payment_intent",
message="Invalid payload: %s" % data,
)
return JsonResponse({"error": "Invalid payload"})
# load our StripeTransaction
try:
our_trans = StripeTransaction.objects.get(pk=payload_cobalt_pay_id)
except ObjectDoesNotExist:
log_event(
request=request,
user=request.user,
severity="ERROR",
source="Payments",
sub_source="stripe_manual_payment_intent",
message="StripeTransaction id: %s not found" % payload_cobalt_pay_id,
)
return JsonResponse({"error": "Invalid payload"})
# Check it
if float(our_trans.amount) * 100.0 != payload_cents:
log_event(
request=request,
user=request.user,
severity="ERROR",
source="Payments",
sub_source="stripe_manual_payment_intent",
message="StripeTransaction id: %s. Browser sent %s cents."
% (payload_cobalt_pay_id, payload_cents),
)
return JsonResponse({"error": "Invalid payload"})
stripe.api_key = STRIPE_SECRET_KEY
# Create a customer so we get the email and name on the Stripe side
# We create a new Stripe customer each time
stripe_customer = stripe.Customer.create(
name=request.user,
email=request.user.email,
)
intent = stripe.PaymentIntent.create(
amount=payload_cents,
currency="aud",
customer=stripe_customer,
description=f"Manual Payment by {request.user}",
metadata={
"cobalt_pay_id": payload_cobalt_pay_id,
"cobalt_tran_type": "Manual",
},
)
log_event(
request=request,
user=request.user,
severity="INFO",
source="Payments",
sub_source="stripe_manual_payment_intent",
message="Created payment intent with Stripe. \
Cobalt_pay_id: %s"
% payload_cobalt_pay_id,
)
print("Stripe manual intent successful")
print(intent)
# Update Status
our_trans.status = "Intent"
our_trans.save()
logger.info(f"Publishable key: {STRIPE_PUBLISHABLE_KEY}")
logger.info(f"clientSecret: {intent.client_secret}")
return JsonResponse(
{
"publishableKey": STRIPE_PUBLISHABLE_KEY,
"clientSecret": intent.client_secret,
}
)
####################################
# stripe_auto_payment_intent #
####################################
[docs]
@login_required()
def stripe_auto_payment_intent(request):
"""Called from the auto top up webpage.
This is very similar to the one off payment. It lets Stripe
know to expect a credit card and provides a token to confirm
which one it is.
When a user is going to set up a credit card we
tell Stripe and Stripe gets ready for it. By this point in the process
we have handed over control to the Stripe code which calls this function
over Ajax.
This functions expects a json payload as part of `request`.
Args:
request - This needs to contain a Json payload.
Notes:
The Json should include:
data{"stripe_customer_id": This is the Stripe customer_id in our table
for the customer that we are handling}
Returns:
json: {'publishableKey':? 'clientSecret':?}
Notes:
publishableKey = our Public Stripe key,
clientSecret = client secret from Stripe
"""
if request.method == "POST":
stripe.api_key = STRIPE_SECRET_KEY
intent = stripe.SetupIntent.create(
customer=request.user.stripe_customer_id,
description=f"Intent to set up auto pay by {request.user}",
metadata={"cobalt_member_id": request.user.id, "cobalt_tran_type": "Auto"},
)
log_event(
request=request,
user=request.user,
severity="INFO",
source="Payments",
sub_source="stripe_auto_payment_intent",
message="Intent created for: %s" % request.user,
)
print("Stripe auto intent successful")
print(intent)
return JsonResponse(
{
"publishableKey": STRIPE_PUBLISHABLE_KEY,
"clientSecret": intent.client_secret,
}
)
return JsonResponse({"error": "POST required"})
###########################
# stripe_current_balance #
###########################
[docs]
def stripe_current_balance():
"""Get our (ABF) current balance with Stripe"""
stripe.api_key = STRIPE_SECRET_KEY
ret = stripe.Balance.retrieve()
# Will be in cents so convert to dollars
# TODO: make this international
return float(ret.available[0].amount / 100.0)
#########################
# stripe_webhook_manual #
#########################
[docs]
def stripe_webhook_manual(event):
"""Handles manual payment events from Stripe webhook
Called by stripe_webhook to look after incoming manual payments.
Args:
event - the event payload from Stripe
Returns:
HTTPResponse code - 200 for success, 400 for error
"""
# get data from payload
charge = event.data.object
message = f"Received charge.succeeded for Manual payment. Our id={charge.metadata.cobalt_pay_id}. Their id={charge.id}"
# TODO: catch error if ids not present
log_event(
user="Stripe API",
severity="INFO",
source="Payments",
sub_source="stripe_webhook",
message=message,
)
logger.info(message)
# Update StripeTransaction
try:
tran = StripeTransaction.objects.get(pk=charge.metadata.cobalt_pay_id)
tran.stripe_reference = charge.id
tran.stripe_method = charge.payment_method
tran.stripe_currency = charge.currency
tran.stripe_receipt_url = charge.receipt_url
tran.stripe_brand = charge.payment_method_details.card.brand
tran.stripe_country = charge.payment_method_details.card.country
tran.stripe_exp_month = charge.payment_method_details.card.exp_month
tran.stripe_exp_year = charge.payment_method_details.card.exp_year
tran.stripe_last4 = charge.payment_method_details.card.last4
tran.stripe_balance_transaction = event.data.object.balance_transaction
tran.last_change_date = timezone.now()
tran.status = "Succeeded"
already = StripeTransaction.objects.filter(
stripe_method=charge.payment_method
).exists()
if not already:
tran.save()
else:
log_event(
user="Stripe API",
severity="CRITICAL",
source="Payments",
sub_source="stripe_webhook",
message=f"Duplicate transaction from Stripe. {charge.payment_method} already present",
)
return HttpResponse(status=200)
except ObjectDoesNotExist:
log_event(
user="Stripe API",
severity="CRITICAL",
source="Payments",
sub_source="stripe_webhook",
message="Unable to load stripe transaction. Check StripeTransaction \
table. Our id=%s - Stripe id=%s"
% (charge.metadata.cobalt_pay_id, charge.id),
)
# TODO: change to 400
return HttpResponse(status=200)
# Set the payment type - this could be for a linked transaction or a manual
# payment.
pay_type = "CC Payment" if tran.linked_transaction_type else "Manual Top Up"
update_account(
member=tran.member,
amount=tran.amount,
stripe_transaction=tran,
description="Payment from card **** **** ***** %s Exp %s/%s"
% (tran.stripe_last4, tran.stripe_exp_month, abs(tran.stripe_exp_year) % 100),
payment_type=pay_type,
)
# Money in from stripe so we can now process the original transaction, if
# we have one. For manual top ups we don't have another transaction and
# linked_transaction_type will be None
if tran.linked_transaction_type:
# We could be linked to a member payment or an organisation payment
if tran.linked_organisation:
update_account(
member=tran.member,
amount=-tran.linked_amount,
description=tran.description,
payment_type=tran.linked_transaction_type,
organisation=tran.linked_organisation,
)
# make organisation payment too
update_organisation(
organisation=tran.linked_organisation,
amount=tran.linked_amount,
description=tran.description,
payment_type=tran.linked_transaction_type,
member=tran.member,
)
if tran.linked_member:
update_account(
member=tran.member,
amount=-tran.linked_amount,
description=tran.description,
payment_type=tran.linked_transaction_type,
other_member=tran.linked_member,
)
# make member payment too
update_account(
member=tran.linked_member,
other_member=tran.member,
amount=tran.linked_amount,
description=tran.description,
payment_type=tran.linked_transaction_type,
)
# make Callback
callback_router(tran.route_code, tran.route_payload, tran)
# success
return HttpResponse(status=200)
##############################
# stripe_webhook_autosetup #
##############################
[docs]
def stripe_webhook_autosetup(event):
"""Handles auto top up setup events from Stripe webhook
Called by stripe_webhook to look after successful incoming auto top up set ups.
Args:
event - the event payload from Stripe
Returns:
HTTPResponse code - 200 for success, 400 for error
"""
# Get customer id
try:
stripe_customer = event.data.object.customer
except AttributeError:
log_event(
user="Stripe API",
severity="CRITICAL",
source="Payments",
sub_source="stripe_webhook",
message="Error retrieving Stripe customer id from message",
)
logger.critical("No customer found on stripe API call")
# If we reply with 400 for example, Stripe will continue to resend. It won't help.
return HttpResponse(status=200)
# find member
member = User.objects.filter(stripe_customer_id=stripe_customer).last()
if not member:
log_event(
user="Stripe API",
severity="CRITICAL",
source="Payments",
sub_source="stripe_webhook",
message=f"Error cannot find member with stripe_customer_id={stripe_customer}",
)
logger.critical(f"Member not found for stripe customer: {stripe_customer}")
return HttpResponse(status=400)
logger.info(f"{member} got auto top up response from Stripe")
# confirm card set up
member.stripe_auto_confirmed = "On"
member.save()
# check if we should make an auto top up now
balance = get_balance(member)
if balance < AUTO_TOP_UP_LOW_LIMIT:
(return_code, message) = auto_topup_member(member)
if return_code: # success
log_event(
user="Stripe API",
severity="INFO",
source="Payments",
sub_source="stripe_webhook",
message=message,
)
else: # failure
log_event(
user="Stripe API",
severity="ERROR",
source="Payments",
sub_source="stripe_webhook",
message=message,
)
return HttpResponse(status=200)
return HttpResponse(status=200)
####################
# stripe_webhook #
####################
[docs]
@require_POST
@csrf_exempt
def stripe_webhook(request):
"""Callback from Stripe webhook
In development, Stripe sends us everything. In production we can configure
the events that we receive. This is the only way for Stripe to communicate
with us.
Note:
Stripe sends us multiple similar things, for example *payment.intent.succeeded*
will accompany anything that uses a payment intent. Be careful to only
handle one out of the multiple events.
For **manual payments** we can receive:
* payment.intent.created - ignore
* charge.succeeded - process
* payment.intent.succeeded - ignore
For **automatic payment set up**, we get:
* customer.created - ignore
* setup_intent.created - ignore
* payment_method.attached - process
* setup_intent.succeeded - ignore
For **automatic payments** we get:
* payment_intent.succeeded - ignore
* payment_intent.created - ignore
* charge_succeeded - ignore (we already know this from the API call)
**Meta data**
We use meta data to track what the event related to. This is added by us
when we call Stripe and returned to us by Stripe in the callback.
Fields used:
* **cobalt_tran_type** - either *Manual* or *Auto* for manual and auto top up
transactions. If this is missing then the transaction is invalid.
* **cobalt_pay_id** - for manual payments this is the linked transaction in
MemberTransaction.
Args:
Stripe json payload - see Stripe documentation
Returns:
HTTPStatus Code
"""
payload = request.body
event = None
try:
event = stripe.Event.construct_from(json.loads(payload), stripe.api_key)
except ValueError as error:
# Invalid payload
log_event(
user="Stripe API",
severity="HIGH",
source="Payments",
sub_source="stripe_webhook",
message=f"Invalid Payload in message from Stripe: {error}",
)
logger.critical(f"Invalid Payload in message from Stripe: {error}")
return HttpResponse(status=400)
# Log message
stripe_log = StripeLog(event=event)
stripe_log.save()
# We get some noise in test environments so filter that out
if event.type not in ["charge.succeeded", "payment_method.attached"]:
logger.info(
f"Ignoring event type from Stripe that we do not want: {event.type}"
)
return HttpResponse()
try:
tran_type = event.data.object.metadata.cobalt_tran_type
except AttributeError:
log_event(
user="Stripe API",
severity="CRITICAL",
source="Payments",
sub_source="stripe_webhook",
message="cobalt_tran_type missing from Stripe webhook",
)
logger.critical(
f"cobalt_tran_type missing from Stripe webhook. metadata was {event.data}"
)
# TODO: change to 400
return HttpResponse(status=200)
stripe_log.cobalt_tran_type = tran_type
stripe_log.event_type = event.type
stripe_log.save()
# We only process change succeeded for Manual charges - for auto topup
# we get this synchronously through the API call, this is additional info.
# Don't process it twice.
if event.type == "charge.succeeded" and tran_type == "Manual":
return stripe_webhook_manual(event)
elif event.type == "payment_method.attached": # auto top up set up successful
return stripe_webhook_autosetup(event)
else:
# Unexpected event type
log_event(
user="Stripe API",
severity="HIGH",
source="Payments",
sub_source="stripe_webhook",
message="Unexpected event received from Stripe - " + event.type,
)
print("Unexpected event found - " + event.type)
# TODO - change to 400
return HttpResponse(status=200)
#########################
# callback_router #
#########################
[docs]
def callback_router(
route_code, route_payload, stripe_transaction=None, status="Success"
):
"""Central function to handle callbacks
Callbacks are an asynchronous way for us to let the calling application
know if a payment succeeded or not.
We could use a routing table for this but there will only ever be a small
number of callbacks in Cobalt so we are okay to hardcode it.
Args:
route_code: (str) hard coded value to map to a function call
route_payload (str) value to return to function
stripe_transaction: StripeTransaction. Optional. Sometimes (e.g. member transfer) we want to get the data from
the Stripe transaction rather than the payload.
status: Success (default) or Failure. Did the payment work or not.
Returns:
Nothing
"""
if not route_code: # do nothing if no route_code passed
return
# Payments made by the main entrant to an event
if route_code == "EVT":
events_core.events_payments_primary_callback(status, route_payload)
# Payments made by other entrants to an event
elif route_code == "EV2":
events_core.events_payments_secondary_callback(status, route_payload)
# Member to member transfers - we also pass the Stripe transaction
elif route_code == "M2M":
member_to_member_transfer_callback(stripe_transaction)
# User Pending Payment
elif route_code == "UPP":
from payments.views.players import user_pending_payment_callback
user_pending_payment_callback(status, route_payload)
# User initiated club membership fee payment
elif route_code == "CAU":
from organisations.views.club_admin import user_initiated_fee_payment_callback
user_initiated_fee_payment_callback(status, route_payload)
else:
log_event(
user="Stripe API",
severity="CRITICAL",
source="Payments",
sub_source="stripe_webhook",
message="Unable to make callback. Invalid route_code: %s" % route_code,
)
######################
# update_account #
######################
[docs]
def update_account(
member,
amount,
description,
payment_type,
stripe_transaction=None,
other_member=None,
organisation=None,
session=None,
):
"""Function to update a customer account by adding a transaction.
args:
member (User): owner of the account
amount (float): value (plus is a deduction, minus is a credit)
description (str): to appear on statement
payment_type (str): type of payment
stripe_transaction (StripeTransaction, optional): linked Stripe transaction
other_member (User, optional): linked member
organisation (organisations.models.Organisation, optional): linked organisation
session (club_sessions.models.Session, optional): club_session.session linked to this transaction
returns:
MemberTransaction
"""
# JPG TESTING - for COB-804 race condition testing
# time.sleep(2)
# Get new balance
balance = get_balance(member) + float(amount)
# Create new MemberTransaction entry
act = MemberTransaction()
act.member = member
act.amount = amount
act.stripe_transaction = stripe_transaction
act.other_member = other_member
act.organisation = organisation
act.balance = balance
act.description = description
act.type = payment_type
if session:
act.club_session_id = session.id
act.save()
return act
#########################
# update_organisation #
#########################
[docs]
def update_organisation(
organisation,
amount,
description,
payment_type,
other_organisation=None,
member=None,
bank_settlement_amount=None,
session=None,
event=None,
):
"""method to update an organisations account
args:
organisation (organisations.models.Organisation): organisation to update
amount (float): value (plus is a deduction, minus is a credit)
description (str): to appear on statement
payment_type (str): type of payment
member (User, optional): linked member
other_organisation (organisations.model.Organisation, optional): linked organisation
bank_settlement_amount (float): How much we expect to be settled. Used for ABF deducting fees for card payments
session (club_sessions.models.Session, optional): club_session.session linked to this transaction
"""
# JPG - needs to take an update lock !!
last_tran = OrganisationTransaction.objects.filter(organisation=organisation).last()
balance = last_tran.balance if last_tran else 0.0
act = OrganisationTransaction()
act.organisation = organisation
act.member = member
act.amount = amount
act.other_organisation = other_organisation
act.balance = float(balance) + float(amount)
act.description = description
act.type = payment_type
act.bank_settlement_amount = bank_settlement_amount
if session:
act.club_session_id = session.id
if event:
act.event_id = event.id
act.save()
return act
###########################
# auto_topup_member #
###########################
[docs]
def auto_topup_member(member, topup_required=None, payment_type="Auto Top Up"):
"""process an auto top up for a member.
Internal function to handle a member needing to process an auto top up.
This function deals with successful top ups and failed top ups. For
failed top ups it will notify the user and disable auto topups. It is
the calling functions problem to handle the consequences of the non-payment.
Args:
member - a User object.
topup_required - the amount of the top up (optional). This is required if the payment
is larger than the top up amount. e.g. balance is 25, top up amount
is 50, payment is 300.
payment_type - defaults to Auto Top Up. We allow this to be overridden so that a member
manually topping up their account using their registered auto top
up card get the payment type of Manual Top Up on their statement.
Returns:
return_code - True for success, False for failure
message - explanation
"""
stripe.api_key = STRIPE_SECRET_KEY
if member.stripe_auto_confirmed != "On":
logger.warning(f"{member} not set up for auto top up")
return False, "Member not set up for Auto Top Up"
if not member.stripe_customer_id:
logger.warning(f"{member} no stripe customer id found for auto top up")
return False, "No Stripe customer id found"
amount = topup_required or member.auto_amount
# Get payment method id for this customer from Stripe
try:
pay_list = stripe.PaymentMethod.list(
customer=member.stripe_customer_id,
type="card",
)
# Use most recent payment if multiple found
pay_method_id = pay_list.data[0].id
except stripe.error.InvalidRequestError as error:
log_event(
user=member,
severity="WARN",
source="Payments",
sub_source="auto_topup_member",
message="Error from stripe - see logs",
)
logger.warning(f"{member} error retrieving payment method from stripe")
return _auto_topup_member_handle_failure(error, member, amount)
# try payment
try:
return _auto_topup_member_stripe_transaction(
amount, member, pay_method_id, payment_type
)
except stripe.error.CardError as error:
return _auto_topup_member_handle_failure(error, member, amount)
def _auto_topup_member_stripe_transaction(amount, member, pay_method_id, payment_type):
"""
Sub process of auto_topup_member to process the happy path of the stripe transaction working.
If we fail, we throw a stripe exception and auto_topup_member will invoke the error handling.
"""
stripe_return = stripe.PaymentIntent.create(
amount=int(amount * 100),
currency="aud",
customer=member.stripe_customer_id,
payment_method=pay_method_id,
description=f"Auto Top Up for {member}",
off_session=True,
confirm=True,
metadata={"cobalt_tran_type": "Auto"},
)
# It worked so create a stripe record
# In the dev environment at least it is possible to get a valid return without
# a 'charges' attribute so the following crashes with a key error:
# payload = stripe_return.charges.data[0]
if "charges" in stripe_return:
payload = stripe_return.charges.data[0]
else:
payload = stripe.Charge.retrieve(stripe_return["latest_charge"])
stripe_tran = StripeTransaction()
stripe_tran.description = "Auto Top Up"
stripe_tran.amount = amount
stripe_tran.member = member
stripe_tran.route_code = None
stripe_tran.route_payload = None
stripe_tran.stripe_reference = payload.id
stripe_tran.stripe_method = payload.payment_method
stripe_tran.stripe_currency = payload.currency
stripe_tran.stripe_receipt_url = payload.receipt_url
stripe_tran.stripe_brand = payload.payment_method_details.card.brand
stripe_tran.stripe_country = payload.payment_method_details.card.country
stripe_tran.stripe_exp_month = payload.payment_method_details.card.exp_month
stripe_tran.stripe_exp_year = payload.payment_method_details.card.exp_year
stripe_tran.stripe_last4 = payload.payment_method_details.card.last4
stripe_tran.stripe_balance_transaction = payload.balance_transaction
stripe_tran.last_change_date = timezone.now()
stripe_tran.status = "Succeeded"
stripe_tran.save()
logger.info(f"Auto top up successful for {member}. Amount={amount}")
# Update members account
update_account(
member=member,
amount=amount,
description="Payment from %s card **** **** ***** %s Exp %s/%s"
% (
payload.payment_method_details.card.brand,
payload.payment_method_details.card.last4,
payload.payment_method_details.card.exp_month,
abs(payload.payment_method_details.card.exp_year) % 100,
),
payment_type=payment_type,
stripe_transaction=stripe_tran,
)
# Notify member
email_body = (
f"Auto top up of {GLOBAL_CURRENCY_SYMBOL}{amount:.2f} into your {BRIDGE_CREDITS} "
f"account was successful.<br><br>"
)
# send
contact_member(
member=member,
msg="Auto top up of %s%s successful" % (GLOBAL_CURRENCY_SYMBOL, amount),
contact_type="Email",
html_msg=email_body,
link="/payments",
subject="Auto top up successful",
)
return (
True,
"Top up successful. %s%.2f added to your account \
from %s card **** **** ***** %s Exp %s/%s"
% (
GLOBAL_CURRENCY_SYMBOL,
amount,
payload.payment_method_details.card.brand,
payload.payment_method_details.card.last4,
payload.payment_method_details.card.exp_month,
abs(payload.payment_method_details.card.exp_year) % 100,
),
)
def _auto_topup_member_handle_failure(error, member, amount):
"""
Sub process of auto_topup_member to handle errors when processing the payment
"""
err = error.error
logger.error(err.message)
# Error code will be authentication_required if authentication is needed
log_event(
user=member,
severity="WARN",
source="Payments",
sub_source="test_autotopup",
message="Error from stripe - %s" % err.message,
)
msg = "%s Auto Top Up has been disabled." % err.message
email_body = """We tried to take a payment of $%.2f from your credit card
but we received this message:
%s
Auto Top Up has been disabled for the time being, but you can
enable it again by clicking below.
<br><br>
""" % (
amount,
err.message,
)
link = reverse("payments:setup_autotopup")
contact_member(
member=member,
msg=msg,
html_msg=email_body,
contact_type="Email",
subject="Auto Top Up Failure",
link=link,
link_text="Auto Top Up",
)
member.stripe_auto_confirmed = "No"
member.save()
return False, "%s Auto Top has been disabled." % err.message
###################################
# org_balance #
###################################
[docs]
def org_balance(organisation):
"""Returns org balance
Args:
organisation (organisations.models.Organisation): Organisation object
Returns:
float: balance
"""
# get balance
last_tran = OrganisationTransaction.objects.filter(organisation=organisation).last()
balance = last_tran.balance if last_tran else 0.0
return float(balance)
###################################
# org_balance_at_date #
###################################
[docs]
def org_balance_at_date(organisation, as_at_date):
"""Returns org balance as at a specified date
Args:
organisation (organisations.models.Organisation): Organisation object
as_at_date: date
Returns:
float: balance
"""
# COB-772
# for end date use 00:00 time on the next day
end_datetime_raw = datetime.datetime.strptime(as_at_date, "%Y-%m-%d")
end_datetime_raw += datetime.timedelta(days=1)
end_datetime = timezone.make_aware(end_datetime_raw, pytz.timezone(TIME_ZONE))
last_tran = (
OrganisationTransaction.objects.filter(
organisation=organisation, created_date__lte=end_datetime
)
.order_by("created_date")
.last()
)
balance = last_tran.balance if last_tran else 0.0
return float(balance)
###################################
# payments_status_summary #
###################################
[docs]
def payments_status_summary():
"""Called by utils to show a management summary of how payments is working.
Args:
None
Returns:
dict: various indicators in a dictionary
"""
stripe_latest = StripeTransaction.objects.filter(status="Succeeded").latest(
"created_date"
)
member_latest = MemberTransaction.objects.latest("created_date")
org_latest = OrganisationTransaction.objects.latest("created_date")
stripe_manual_pending = StripeTransaction.objects.filter(status="Pending")
stripe_auto_pending = User.objects.filter(stripe_auto_confirmed="Pending")
if stripe_manual_pending or stripe_auto_pending: # errors
status = "Bad"
else:
status = "Good"
return {
"stripe_latest": stripe_latest,
"member_latest": member_latest,
"org_latest": org_latest,
"manual_pending": stripe_manual_pending,
"auto_pending": stripe_auto_pending,
"status": status,
}
[docs]
def statement_common(user):
"""Member statement view - common part across online, pdf and csv
Handles the non-formatting parts of statements.
Args:
user (User): standard user object
Returns:
5-element tuple containing
- **summary** (*dict*): Basic info about user from MasterPoints
- **club** (*str*): Home club name
- **balance** (*float* or *str*): Users account balance
- **auto_button** (*bool*): status of auto top up
- **events_list** (*list*): list of MemberTransactions
"""
# Get summary data
qry = "%s/mps/%s" % (GLOBAL_MPSERVER, user.system_number)
try:
summary = requests.get(qry).json()[0]
except (IndexError, JSONDecodeError): # server down or some error
# raise Http404
summary = {"IsActive": False, "HomeClubID": 0}
# Set active to a boolean
summary["IsActive"] = summary["IsActive"] == "Y"
# Get home club name
qry = "%s/club/%s" % (GLOBAL_MPSERVER, summary["HomeClubID"])
try:
club = requests.get(qry).json()[0]["ClubName"]
except (IndexError, JSONDecodeError): # server down or some error
club = "Unknown"
# get balance
last_tran = (
MemberTransaction.objects.filter(member=user).order_by("created_date").last()
)
balance = last_tran.balance if last_tran else "Nil"
# get auto top up
auto_button = user.stripe_auto_confirmed == "On"
events_list = (
MemberTransaction.objects.filter(member=user)
.select_related("member", "other_member")
.order_by("-created_date")
)
return summary, club, balance, auto_button, events_list
[docs]
def member_to_member_transfer_callback(stripe_transaction=None):
"""Callback for member to member transfers. We have already made the payments, so just let people know.
We will get a stripe_transaction from the manual payment screen (callback from stripe webhook).
If we don't get one that is because this was handled already so ignore.
Three scenarios (doesn't matter if manual or auto top up):
1. The user had enough funds to pay the other member - emails already sent, stripe_tran = None
2. The user paid the full amount on their credit card - stripe amount = transfer amount
3. The user had some funds and paid the rest on their credit card - stripe amt + previous bal = transfer amount
"""
if not stripe_transaction:
return
logger.info(f"Callback received with {stripe_transaction}")
# Get other member
# We will have a stripe_transaction that is linked to a member_transaction, that is the member_transaction for
# this member (the one who paid). The very next transaction in their account will be the outgoing payment.
this_member_transaction = MemberTransaction.objects.filter(
stripe_transaction=stripe_transaction
).first()
if not this_member_transaction:
logger.info(
f"Could not find matching member transaction for {stripe_transaction}"
)
return HttpResponse()
logger.info(
f"Found member transaction: {this_member_transaction.id} {this_member_transaction}"
)
# Get transaction after this_member_transaction (id>) for this member This will be the other member transaction
# as these are booked simultaneously, and only moments before this code runs
other_member_transaction = (
MemberTransaction.objects.filter(member=this_member_transaction.member)
.filter(id__gt=this_member_transaction.id)
.first()
)
if not other_member_transaction:
logger.error(
f"Could not find matching outgoing member transaction for {stripe_transaction}"
)
return HttpResponse()
if not other_member_transaction.other_member:
logger.error(f"No other_member found on {other_member_transaction}")
return HttpResponse()
# We use the same function in payments API that is used for sufficient funds
# Note - this could be a partial payment so use the negative of the other_member_transaction,
# not the stripe_transaction
notify_member_to_member_transfer(
stripe_transaction.member,
other_member_transaction.other_member,
-other_member_transaction.amount,
stripe_transaction.description,
)
return HttpResponse()
[docs]
def get_payments_statistics():
"""Get statistics about payments. Called by utils statistics"""
# Static
payment_static = PaymentStatic.objects.filter(active=True).last()
members_who_have_made_payments = MemberTransaction.objects.distinct(
"member"
).count()
total_stripe_transactions = StripeTransaction.objects.count()
total_stripe_payment_amount = StripeTransaction.objects.filter(
status__in=["Succeeded", "Partial refund", "Refunded"]
).aggregate(Sum("amount"))["amount__sum"]
total_stripe_payment_amount_refunds = StripeTransaction.objects.aggregate(
Sum("refund_amount")
)["refund_amount__sum"]
total_stripe_payment_amount_less_refunds = (
total_stripe_payment_amount - total_stripe_payment_amount_refunds
)
# Anything positive that moves counts as turnover
total_turnover = (
MemberTransaction.objects.filter(amount__gt=0).aggregate(sum=Sum("amount"))[
"sum"
]
+ OrganisationTransaction.objects.filter(amount__gt=0).aggregate(
sum=Sum("amount")
)["sum"]
+ StripeTransaction.objects.filter(amount__gt=0).aggregate(sum=Sum("amount"))[
"sum"
]
)
# Average Stripe transaction
average_stripe_transaction = (
float(total_stripe_payment_amount) / total_stripe_transactions
)
# ABF cut of things
try:
abf_fees = (
# Now we include exact fee on transaction
OrganisationTransaction.objects.filter(type="Settlement")
.filter(bank_settlement_amount__gt=0)
.annotate(abf_fee=-F("amount") - F("bank_settlement_amount"))
.aggregate(sum=Sum("abf_fee"))["sum"]
# Previously we didn't but it was 2% of value
) - OrganisationTransaction.objects.filter(type="Settlement").exclude(
bank_settlement_amount__gt=0
).aggregate(
sum=Sum("amount")
)[
"sum"
] * Decimal(
0.02
)
except TypeError:
abf_fees = 0
# stripe cut of things - won't cope with rate changes but could be modified
estimated_stripe_fees = (
float(total_stripe_payment_amount_less_refunds)
* float(payment_static.stripe_percentage_charge)
/ 100
) + (float(payment_static.stripe_cost_per_transaction) * total_stripe_transactions)
return {
"members_who_have_made_payments": members_who_have_made_payments,
"total_stripe_payment_amount": total_stripe_payment_amount,
"total_stripe_transactions": total_stripe_transactions,
"total_stripe_payment_amount_refunds": total_stripe_payment_amount_refunds,
"total_stripe_payment_amount_less_refunds": total_stripe_payment_amount_less_refunds,
"total_turnover": total_turnover,
"abf_fees": abf_fees,
"estimated_stripe_fees": estimated_stripe_fees,
"average_stripe_transaction": average_stripe_transaction,
}
[docs]
def low_balance_warning(member: User):
"""Handle a user without auto top up enabled, falling below the low balance threshold"""
if not member.receive_low_balance_emails:
return
# Get data
recent_transactions = MemberTransaction.objects.filter(member=member).order_by(
"-pk"
)[:5]
balance = get_balance(member)
# Set up links
auto_top_up_users = User.objects.filter(stripe_auto_confirmed="On").count()
auto_reverse = reverse("payments:setup_autotopup")
auto_link = f"{COBALT_HOSTNAME}{auto_reverse}"
statement_reverse = reverse("payments:payments")
statement_link = f"{COBALT_HOSTNAME}{statement_reverse}"
settings_reverse = reverse("accounts:user_settings")
settings_link = f"{COBALT_HOSTNAME}{settings_reverse}"
additional_words = f"""<p style="font-size: 12px"><i>Don't want to be told about low balances?
You can change this through your <a href="http://{settings_link}" target="_blank">settings</a>.</p>"""
# run template
template = get_template("payments/players/low_balance_warning.html")
email_body = template.render(
{
"member": member,
"recent_transactions": recent_transactions,
"balance": balance,
"auto_top_up_users": auto_top_up_users,
"GLOBAL_TITLE": GLOBAL_TITLE,
"auto_link": auto_link,
"statement_link": statement_link,
"COBALT_HOSTNAME": COBALT_HOSTNAME,
}
)
# set up context
context = {
"name": member.first_name,
"title": "Low Balance Warning",
"email_body": email_body,
"box_colour": "#dc3545",
"link": reverse("payments:manual_topup"),
"link_text": "Manual Top Up Now",
"additional_words": additional_words,
}
# send email
send_cobalt_email_with_template(to_address=member.email, context=context)