Skip to content

Commit

Permalink
Implement new Mobilepay reporting API (#416)
Browse files Browse the repository at this point in the history
* Duplicate command script

* Ledger retrieval

* Update token retrieval payload and endpoint

* Reformat file

* Correct refresh access token-method

I had accidentally included a header item in the payload, and passed a wrong value in payload. Furthermore, the authorization string is now encoded with base64

* Update import mobilepay payment to new API

The equivalent fields have been changed

* Use new date-specific endpoint

Had to rewrite fetch_transactions to accumulate multiple calls

* Correct use of new transaction API

I've corrected query parameters and the handling of response

* Add manual cutoff date to prevent double payments

If someone where to accidentally run imports back into manual payments, then they'd be registered twice (I think)

* Remove unused method

* Refactor get_transactions

* Refresh ledger id as well, when it's missing

* Remove duplicate file and move changes into original

I regret making the changes to a duplicate instead of simply changing the existing file

* Refactor parameter

* Assert ledger info

* Handle incomplete token-file

* Type annotation and proper handling

* Correct types used and log error

* Use HTTPBasicAuth helper class

* Update stregsystem/management/commands/importmobilepaypayments.py

Co-authored-by: Falke Carlsen <[email protected]>

---------

Co-authored-by: Falke Carlsen <[email protected]>
  • Loading branch information
krestenlaust and falkecarlsen authored Apr 8, 2024
1 parent ecc5358 commit 42823d0
Showing 1 changed file with 108 additions and 34 deletions.
142 changes: 108 additions & 34 deletions stregsystem/management/commands/importmobilepaypayments.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta, date
from django.core.management.base import BaseCommand
from django.utils.dateparse import parse_datetime
from pathlib import Path

from requests import HTTPError
from requests.auth import HTTPBasicAuth

from stregsystem.models import MobilePayment
import json
Expand All @@ -16,12 +17,15 @@
class Command(BaseCommand):
help = 'Imports the latest payments from MobilePay'

api_endpoint = 'https://api.mobilepay.dk'
api_endpoint = 'https://api.vipps.no'
# Saves secret tokens to the file "tokens.json" right next to this file.
# Important to use a separate file since the tokens can change and is thus not suitable for django settings.
tokens_file = (Path(__file__).parent / 'tokens.json').as_posix()
tokens_file_backup = (Path(__file__).parent / 'tokens.json.bak').as_posix()
tokens = None
# Cutoff for when this iteration of the Mobilepay-API (Vipps) is deployed
manual_cutoff_date = date(2024, 4, 9)
myshop_number = 90601

logger = logging.getLogger(__name__)
days_back = None
Expand Down Expand Up @@ -73,68 +77,81 @@ def update_token_storage(self):

# Fetches a new access token using the refresh token.
def refresh_access_token(self):
url = f"{self.api_endpoint}/merchant-authentication-openidconnect/connect/token"
url = f"{self.api_endpoint}/miami/v1/token"

payload = {
"grant_type": "refresh_token",
"refresh_token": self.tokens['refresh_token'],
"client_id": self.tokens['zip-client-id'],
"client_secret": self.tokens['zip-client-secret'],
"grant_type": "client_credentials",
}
response = requests.post(url, data=payload)

auth = HTTPBasicAuth(self.tokens['client_id'], self.tokens['client_secret'])

response = requests.post(url, data=payload, auth=auth)
response.raise_for_status()
json_response = response.json()
# Calculate when the token expires
expire_time = datetime.now() + timedelta(seconds=json_response['expires_in'] - 1)
self.tokens['access_token_timeout'] = expire_time.isoformat(timespec='milliseconds')
self.tokens['access_token'] = json_response['access_token']
self.update_token_storage()

# Format to timestamp format. Source:
# https://github.com/MobilePayDev/MobilePay-TransactionReporting-API/blob/master/docs/api/types.md#timestamp
@staticmethod
def format_datetime(inputdatetime):
return f"{inputdatetime.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3]}Z"
def refresh_ledger_id(self):
self.tokens['ledger_id'] = self.get_ledger_id(self.myshop_number)

# Fetches the transactions for a given payment-point (MobilePay phone-number) in a given period (from-to)
def get_transactions(self):
url = f"{self.api_endpoint}/transaction-reporting/api/merchant/v1/paymentpoints/{self.tokens['paymentpoint']}/transactions"
current_time = datetime.now(timezone.utc)
def get_transactions(self, transaction_date: date):
ledger_date = transaction_date.strftime('%Y-%m-%d')

url = f"{self.api_endpoint}/report/v2/ledgers/{self.tokens['ledger_id']}/funds/dates/{ledger_date}"

params = {
'from': self.format_datetime(current_time - timedelta(days=self.days_back)),
'to': self.format_datetime(current_time),
'includeGDPRSensitiveData': "true",
}
headers = {
'x-ibm-client-secret': self.tokens['ibm-client-secret'],
'x-ibm-client-id': self.tokens['ibm-client-id'],
'authorization': 'Bearer {}'.format(self.tokens['access_token']),
}
response = requests.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()['transactions']
return response.json()['items']

# Client side check if the token has expired.
def refresh_expired_token(self):
self.read_token_storage()

if 'access_token_timeout' not in self.tokens:
self.refresh_access_token()

expire_time = parse_datetime(self.tokens['access_token_timeout'])
if datetime.now() >= expire_time:
self.refresh_access_token()

def fetch_transactions(self):
if 'ledger_id' not in self.tokens:
self.refresh_ledger_id()

self.update_token_storage()

def fetch_transactions(self) -> list:
# Do a client side check if token is good. If not - fetch another token.
try:
self.refresh_expired_token()
assert self.days_back is not None
return self.get_transactions()

transactions = []

for i in range(self.days_back):
past_date = date.today() - timedelta(days=i)
if past_date < self.manual_cutoff_date:
break

transactions.extend(self.get_transactions(past_date))

return transactions
except HTTPError as e:
self.write_error(f"Got an HTTP error when trying to fetch transactions: {e.response}")
except Exception as e:
self.write_error(f'Got an error when trying to fetch transactions.')
pass
self.write_error(f'Got an error when trying to fetch transactions: {e}')

def import_mobilepay_payments(self):
transactions = self.fetch_transactions()
if transactions is None:
if len(transactions) == 0:
self.write_info(f'Ran, but no transactions found')
return

Expand All @@ -144,28 +161,48 @@ def import_mobilepay_payments(self):
self.write_info('Successfully ran MobilePayment API import')

def import_mobilepay_payment(self, transaction):
if transaction['type'] != 'Payment':
"""
Example of a transaction:
{
"pspReference": "32212390715",
"time": "2024-04-05T07:19:26.528092Z",
"ledgerDate": "2024-04-05",
"entryType": "capture",
"reference": "10113143347",
"currency": "DKK",
"amount": 20000,
"recipientHandle": "DK:90601",
"balanceAfter": 110000,
"balanceBefore": 90000,
"name": "Jakob Topper",
"maskedPhoneNo": "xxxx 1234",
"message": "Topper"
}
:param transaction:
:return:
"""
if transaction['entryType'] != 'capture':
return

trans_id = transaction['paymentTransactionId']
trans_id = transaction['pspReference']

if MobilePayment.objects.filter(transaction_id=trans_id).exists():
self.write_debug(f'Skipping transaction since it already exists (Transaction ID: {trans_id})')
self.write_debug(f'Skipping transaction since it already exists (PSP-Reference: {trans_id})')
return

currency_code = transaction['currencyCode']
currency_code = transaction['currency']
if currency_code != 'DKK':
self.write_warning(f'Does ONLY support DKK (Transaction ID: {trans_id}), was {currency_code}')
return

amount = transaction['amount']

comment = strip_emoji(transaction['senderComment'])
comment = strip_emoji(transaction['message'])

payment_datetime = parse_datetime(transaction['timestamp'])
payment_datetime = parse_datetime(transaction['time'])

MobilePayment.objects.create(
amount=amount * 100, # convert to streg-ører
amount=amount, # already in streg-ører
member=mobile_payment_exact_match_member(comment),
comment=comment,
timestamp=payment_datetime,
Expand All @@ -174,3 +211,40 @@ def import_mobilepay_payment(self, transaction):
)

self.write_info(f'Imported transaction id: {trans_id} for amount: {amount}')

def get_ledger_info(self, myshop_number: int):
"""
{
"ledgerId": "123456",
"currency": "DKK",
"payoutBankAccount": {
"scheme": "BBAN:DK",
"id": "123412341234123412"
},
"owner": {
"scheme": "business:DK:CVR",
"id": "16427888"
},
"settlesForRecipientHandles": [
"DK:90601"
]
}
:param myshop_number:
:return:
"""
url = f"{self.api_endpoint}/settlement/v1/ledgers"
params = {'settlesForRecipientHandles': 'DK:{}'.format(myshop_number)}
headers = {
'authorization': 'Bearer {}'.format(self.tokens['access_token']),
}
response = requests.get(url, params=params, headers=headers)
response.raise_for_status()

ledger_info = response.json()["items"]

assert len(ledger_info) != 0

return ledger_info[0]

def get_ledger_id(self, myshop_number: int) -> int:
return int(self.get_ledger_info(myshop_number)["ledgerId"])

0 comments on commit 42823d0

Please sign in to comment.