Skip to content

Commit

Permalink
feat: add Adjustment model and admin
Browse files Browse the repository at this point in the history
ENT-7802 | Adds the ability to credit (or debit) manually from a ledger,
outside the context of a redemption/enrollment, in the event of
dispute or confusion.
  • Loading branch information
iloveagent57 committed Oct 17, 2023
1 parent 0f2e264 commit 2f4b8f0
Show file tree
Hide file tree
Showing 13 changed files with 590 additions and 11 deletions.
2 changes: 2 additions & 0 deletions .annotation_safe_list.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ openedx_ledger.HistoricalReversal:
".. no_pii:": "This model has no PII"
openedx_ledger.HistoricalTransaction:
".. no_pii:": "This model has no PII"
openedx_ledger.HistoricalAdjustment:
".. no_pii:": "This model has no PII"
sessions.Session:
".. no_pii:": "This model has no PII"
social_django.Association:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Unreleased
**********
* Nothing unreleased

[1.2.0]
*******
* Add an ``Adjustment`` model

[1.1.0]
*******
* Add support for Django 4.2
Expand Down
55 changes: 55 additions & 0 deletions docs/decisions/0007-adjustments.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
0007 Ledger Adjustments
#######################

Status
******
**Accepted** (October 2023)

Context
*******
We'd like to have the ability to manually adjust the balance of ledgers
in a somewhat unstructured way - that is, in a way that doesn't reverse
an existing transaction. For example, the manager of a customer account
may need to manually "credit" a ledger when:

* a learner manages to redeem an enrollment via a ledger in a course that our
system should not have allowed them to access at the time of redemption.
* a learner had unforseen technical challenges in taking the course, but
is not able to officially unenroll/reverse the redemption.
* some client-relationship building needs to take place.

Decision
********
We'll introduce an ``Adjustment`` model:

* This is somewhat different from our existing pattern of modeling any modification
to a ledger via models that inherit from ``BaseTransaction`` (like we
do with ``Reversal``).
* Creating an ``Adjustment`` will cause a new ``Transaction`` to be written
to the adjustment's ledger object, with the same quantity as the adjustment record.
This transaction record is the thing that fundamentally changes the
balance of the related ledger. The transaction will be referred to from a foreign key of the ``Adjustment``
record.
* An adjustment must define some enumerated ``reason`` for being created. The ``reason`` should
be from a fixed set of choices, and those choices should generally **not** overlap
with the strict notion of reversing a transaction.
* An ``Adjustment`` *may* refer to a ``transaction_of_interest`` - this is a foreign
key to another transaction of note that is relevant to the reason for the
creation of the adjustment. It is not required.
* An adjustment *may* include some free-text ``notes`` that help to further
explain why the adjustment exists.

Consequences
************
* Adjustments work in kind of the opposite way of reversals: instead of using an
existing ledger-transaction to instantiate a reversal, we'll have a situation
where the action of creating an adjustment happens, which has a side-effect
of creating a transaction to adjust the ledger balance.
* Care must be taken to ensure that adjustments are not over-used. For one, using them
to transfer balance from a customer to a ledger upon the expiration of our business
contracts (i.e. to "renew" a contract) could be inappropriate or destructive from
a back-office recordkeeping perspective. And they should never be used in place
of ``Reversals`` when the latter are applicable.
* It would be beneficial to observe and track the usage of ``Adjustments``
over time within the system, and perhaps to restrict their usage
via Django Admin tools or "hard" thresholds.
2 changes: 1 addition & 1 deletion openedx_ledger/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""
A library that records transactions against a ledger, denominated in units of value.
"""
__version__ = "1.1.0"
__version__ = "1.2.0"
170 changes: 167 additions & 3 deletions openedx_ledger/admin.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"""
Admin configuration for openedx_ledger models.
"""
from django import forms
from django.conf import settings
from django.contrib import admin
from django.http import HttpResponseRedirect
from django.urls import re_path, reverse
from django_object_actions import DjangoObjectActions
from simple_history.admin import SimpleHistoryAdmin

from openedx_ledger import constants, models, views
from openedx_ledger import api, constants, models, views


def can_modify():
Expand Down Expand Up @@ -36,6 +37,12 @@ class Meta:
model = models.Ledger

fields = ('uuid', 'idempotency_key', 'unit', 'balance_usd', 'metadata')
# The autocomplete_fields of AdjustmentAdmin include the ledger field,
# which in turn requires that we define here that ledgers are
# searchable by uuid.
search_fields = [
'uuid',
]
if can_modify():
readonly_fields = ('uuid', 'balance_usd')
else:
Expand Down Expand Up @@ -75,6 +82,29 @@ class ExternalTransactionReferenceInlineAdmin(admin.TabularInline):
model = models.ExternalTransactionReference


class AdjustmentInlineAdmin(admin.TabularInline):
"""
Inline admin configuration for the Adjustment model.
"""
model = models.Adjustment
fk_name = 'transaction'
fields = [
'uuid',
'get_quantity_usd',
'reason',
'created',
'modified',
]
readonly_fields = fields
show_change_link = True

@admin.display(description='Amount in U.S. Dollars')
def get_quantity_usd(self, obj):
if not obj._state.adding: # pylint: disable=protected-access
return cents_to_usd_string(obj.adjustment_quantity)
return None


@admin.register(models.Transaction)
class TransactionAdmin(DjangoObjectActions, SimpleHistoryAdmin):
"""
Expand All @@ -98,7 +128,7 @@ class Meta:
)
_all_fields = [
field.name for field in models.Transaction._meta.get_fields()
if field.name != 'external_reference'
if field.name not in {'external_reference', 'adjustment', 'adjustment_of_interest'}
]
_writable_fields = [
'fulfillment_identifier',
Expand All @@ -124,7 +154,11 @@ class Meta:
]
else:
readonly_fields = _all_fields
inlines = [ExternalTransactionReferenceInlineAdmin]

def get_inlines(self, request, obj):
if obj and not obj._state.adding: # pylint: disable=protected-access
return [AdjustmentInlineAdmin, ExternalTransactionReferenceInlineAdmin]
return [ExternalTransactionReferenceInlineAdmin]

@admin.display(ordering='reversal', description='Has a reversal')
def has_reversal(self, obj):
Expand Down Expand Up @@ -176,3 +210,133 @@ class Meta:

model = models.Reversal
fields = '__all__'


class AdjustmentAdminCreateForm(forms.ModelForm):
"""
Form that allows users to enter adjustment quantities in dollars
instead of cents.
"""
class Meta:
model = models.Adjustment
fields = [
'ledger',
'quantity_usd_input',
'reason',
'notes',
'transaction_of_interest',
'transaction',
'adjustment_quantity',
]

quantity_usd_input = forms.FloatField(
required=True,
help_text='Amount of adjustment in US Dollars.',
)


class AdjustmentAdminChangeForm(forms.ModelForm):
"""
Form for reading and changing only the allowed fields of an existing adjustment record.
"""
class Meta:
model = models.Adjustment
fields = [
'ledger',
'reason',
'notes',
'transaction_of_interest',
'transaction',
'adjustment_quantity',
]


@admin.register(models.Adjustment)
class AdjustmentAdmin(SimpleHistoryAdmin):
"""
Admin configuration for the Adjustment model.
"""
form = AdjustmentAdminCreateForm

_readonly_fields = [
'get_quantity_usd',
'uuid',
'transaction',
'adjustment_quantity',
'created',
'modified',
]

list_display = (
'uuid',
'get_ledger_uuid',
'get_quantity_usd',
'reason',
'created',
'modified',
)
list_filter = (
'reason',
)
autocomplete_fields = [
'ledger',
'transaction_of_interest',
]

def get_readonly_fields(self, request, obj=None):
"""
Don't allow changing the ledger if we've already saved the adjustment record.
"""
if obj and not obj._state.adding: # pylint: disable=protected-access
return ['ledger'] + self._readonly_fields
return self._readonly_fields

def get_fields(self, request, obj=None):
"""
Don't include the ``quantity_usd_input`` field unless we're creating a new adjustment.
"""
# When we're adding a new adjustment, use default fields
if not obj:
return super().get_fields(request, obj)
else:
# Don't show the USD amount input field on read/change
return [
field for field in super().get_fields(request, obj)
if field != 'quantity_usd_input'
]

def get_form(self, request, obj=None, **kwargs): # pylint: disable=arguments-differ
"""
Don't worry about validating the ``quantity_usd_input`` unless we're creating a new adjustment.
"""
if obj and not obj._state.adding: # pylint: disable=protected-access
kwargs['form'] = AdjustmentAdminChangeForm
return super().get_form(request, obj, **kwargs)

@admin.display(description='Amount in U.S. Dollars')
def get_quantity_usd(self, obj):
if not obj._state.adding: # pylint: disable=protected-access
return cents_to_usd_string(obj.adjustment_quantity)
return None

@admin.display(ordering='uuid', description='Ledger uuid')
def get_ledger_uuid(self, obj):
return obj.ledger.uuid

def save_model(self, request, obj, form, change):
if change:
super().save_model(request, obj, form, change)
else:
raw_usd_input = form.cleaned_data.get('quantity_usd_input')
quantity_usd_cents = raw_usd_input * constants.CENTS_PER_US_DOLLAR
# AED 2023-10-16: Use the auto-generated "stub" UUID for the Adjustment record
# to persist the Adjustment record, so that Django Admin doesn't get lost
# when a user clicks "Save and Continue Editing".
api.create_adjustment(
adjustment_uuid=obj.uuid,
ledger=obj.ledger,
quantity=quantity_usd_cents,
reason=obj.reason,
notes=obj.notes,
transaction_of_interest=obj.transaction_of_interest,
)
Loading

0 comments on commit 2f4b8f0

Please sign in to comment.