allnet: save billing traces

develop
Hay1tsme 2025-04-17 20:11:33 -04:00
parent ada9377c06
commit ce475e801b
5 changed files with 280 additions and 38 deletions

View File

@ -586,31 +586,12 @@ class BillingServlet:
rsa = RSA.import_key(open(self.config.billing.signing_key, "rb").read())
signer = PKCS1_v1_5.new(rsa)
digest = SHA.new()
traces: List[TraceData] = []
try:
req = BillingInfo(req_dict[0])
except KeyError as e:
self.logger.error(f"Billing request failed to parse: {e}")
return PlainTextResponse("result=5&linelimit=&message=field is missing or formatting is incorrect\r\n")
for x in range(1, len(req_dict)):
if not req_dict[x]:
continue
try:
tmp = TraceData(req_dict[x])
if tmp.trace_type == TraceDataType.CHARGE:
tmp = TraceDataCharge(req_dict[x])
elif tmp.trace_type == TraceDataType.EVENT:
tmp = TraceDataEvent(req_dict[x])
elif tmp.trace_type == TraceDataType.CREDIT:
tmp = TraceDataCredit(req_dict[x])
traces.append(tmp)
except KeyError as e:
self.logger.warning(f"Tracelog failed to parse: {e}")
kc_serial_bytes = req.keychipid.encode()
@ -618,7 +599,7 @@ class BillingServlet:
if machine is None and not self.config.server.allow_unregistered_serials:
msg = f"Unrecognised serial {req.keychipid} attempted billing checkin from {request_ip} for {req.gameid} v{req.gamever}."
await self.data.base.log_event(
"allnet", "BILLING_CHECKIN_NG_SERIAL", logging.WARN, msg, ip=request_ip, game=req.gameid, version=req.gamever
"allnet", "BILLING_CHECKIN_NG_SERIAL", logging.WARN, msg, ip=request_ip, game=req.gameid, version=str(req.gamever)
)
self.logger.warning(msg)
@ -629,18 +610,79 @@ class BillingServlet:
"billing_type": req.billingtype.name,
"nearfull": req.nearfull,
"playlimit": req.playlimit,
"messages": []
}
if machine is not None:
await self.data.base.log_event("billing", "BILLING_CHECKIN_OK", logging.INFO, "", log_details, None, machine['arcade'], machine['id'], request_ip, req.gameid, req.gamever)
for x in range(1, len(req_dict)):
if not req_dict[x]:
continue
try:
tmp = TraceData(req_dict[x])
if tmp.trace_type == TraceDataType.CHARGE:
tmp = TraceDataCharge(req_dict[x])
if self.config.allnet.save_billing:
await self.data.arcade.billing_add_charge(
machine['id'],
tmp.game_id,
float(tmp.game_version),
tmp.play_count,
tmp.play_limit,
tmp.product_code,
tmp.product_count,
tmp.func_type,
tmp.player_number
)
self.logger.info(
f"Charge Trace from {req.keychipid}: {tmp.game_id} v{tmp.game_version} - player {tmp.player_number} got {tmp.product_count} of {tmp.product_code} func {tmp.func_type}"
)
elif tmp.trace_type == TraceDataType.EVENT:
tmp = TraceDataEvent(req_dict[x])
log_details['messages'].append(tmp.message)
self.logger.info(f"Event Trace from {req.keychipid}: {tmp.message}")
elif tmp.trace_type == TraceDataType.CREDIT:
tmp = TraceDataCredit(req_dict[x])
if self.config.allnet.save_billing:
await self.data.arcade.billing_set_credit(
machine['id'],
tmp.chute_type.value,
tmp.service_type.value,
tmp.operation_type.value,
tmp.coin_rate0,
tmp.coin_rate1,
tmp.bonus_addition,
tmp.credit_rate,
tmp.credit0,
tmp.credit1,
tmp.credit2,
tmp.credit3,
tmp.credit4,
tmp.credit5,
tmp.credit6,
tmp.credit7
)
self.logger.info(
f"Credit Trace from {req.keychipid}: {tmp.operation_type} mode, {tmp.credit_rate} coins per credit, Consumed {tmp.credit0} | {tmp.credit1} | {tmp.credit2} | {tmp.credit3} | {tmp.credit4} | {tmp.credit5} | {tmp.credit6} | {tmp.credit7} | "
)
except KeyError as e:
self.logger.warning(f"Tracelog failed to parse: {e}")
await self.data.base.log_event("billing", "BILLING_CHECKIN_OK", logging.INFO, "", log_details, None, machine['arcade'], machine['id'], request_ip, req.gameid, str(req.gamever))
self.logger.info(
f"Billing checkin from {request_ip}: game {req.gameid} ver {req.gamever} keychip {req.keychipid} playcount "
f"{req.playcnt} billing_type {req.billingtype.name} nearfull {req.nearfull} playlimit {req.playlimit}"
)
else:
log_details['serial'] = req.keychipid
await self.data.base.log_event("billing", "BILLING_CHECKIN_OK_UNREG", logging.INFO, "", log_details, None, None, None, request_ip, req.gameid, req.gamever)
await self.data.base.log_event("billing", "BILLING_CHECKIN_OK_UNREG", logging.INFO, "", log_details, None, None, None, request_ip, req.gameid, str(req.gamever))
self.logger.info(
f"Unregistered Billing checkin from {request_ip}: game {req.gameid} ver {req.gamever} keychip {req.keychipid} playcount "
@ -768,14 +810,27 @@ class BillingType(Enum):
A = 1
B = 0
class TraceDataCreditChuteType(Enum):
COMMON = 0
INDIVIDUAL = 1
class TraceDataCreditOperationType(Enum):
COIN = 0
FREEPLAY = 1
class float5:
def __init__(self, n: str = "0") -> None:
def __init__(self, n: str = "0"):
nf = float(n)
if nf > 999.9 or nf < 0:
raise ValueError('float5 must be between 0.000 and 999.9 inclusive')
return nf
self.val = nf
def __float__(self) -> float:
return self.val
def __str__(self) -> str:
return f"%.{2 - int(math.log10(self.val))+1}f" % self.val
@classmethod
def to_str(cls, f: float):
return f"%.{2 - int(math.log10(f))+1}f" % f
@ -786,13 +841,13 @@ class BillingInfo:
self.keychipid = str(data.get("keychipid", None))
self.functype = int(data.get("functype", None))
self.gameid = str(data.get("gameid", None))
self.gamever = float(data.get("gamever", None))
self.gamever = float5(data.get("gamever", None))
self.boardid = str(data.get("boardid", None))
self.tenpoip = str(data.get("tenpoip", None))
self.libalibver = float(data.get("libalibver", None))
self.libalibver = float5(data.get("libalibver", None))
self.datamax = int(data.get("datamax", None))
self.billingtype = BillingType(int(data.get("billingtype", None)))
self.protocolver = float(data.get("protocolver", None))
self.protocolver = float5(data.get("protocolver", None))
self.operatingfix = bool(data.get("operatingfix", None))
self.traceleft = int(data.get("traceleft", None))
self.requestno = int(data.get("requestno", None))
@ -825,7 +880,7 @@ class TraceData:
self.date = datetime.strptime(data.get("dt", None), BILLING_DT_FORMAT)
self.keychip = str(data.get("kn", None))
self.lib_ver = float(data.get("alib", 0))
self.lib_ver = float5(data.get("alib", 0))
except Exception as e:
raise KeyError(e)
@ -834,7 +889,7 @@ class TraceDataCharge(TraceData):
super().__init__(data)
try:
self.game_id = str(data.get("gi", None)) # these seem optional...?
self.game_version = float(data.get("gv", 0))
self.game_version = float5(data.get("gv", 0))
self.board_serial = str(data.get("bn", None))
self.shop_ip = str(data.get("ti", None))
self.play_count = int(data.get("pc", None))
@ -858,9 +913,9 @@ class TraceDataCredit(TraceData):
def __init__(self, data: Dict) -> None:
super().__init__(data)
try:
self.chute_type = int(data.get("cct", None))
self.service_type = int(data.get("cst", None))
self.operation_type = int(data.get("cop", None))
self.chute_type = TraceDataCreditChuteType(int(data.get("cct", None)))
self.service_type = TraceDataCreditChuteType(int(data.get("cst", None)))
self.operation_type = TraceDataCreditOperationType(int(data.get("cop", None)))
self.coin_rate0 = int(data.get("cr0", None))
self.coin_rate1 = int(data.get("cr1", None))
self.bonus_addition = int(data.get("cba", None))
@ -884,7 +939,7 @@ class BillingResponse:
nearfull: str = "",
nearfull_sig: str = "",
request_num: int = 1,
protocol_ver: float = 1.000,
protocol_ver: float5 = float5("1.000"),
playhistory: str = "000000/0:000000/0:000000/0",
) -> None:
self.result = 0
@ -898,7 +953,7 @@ class BillingResponse:
self.nearfull = nearfull
self.nearfullsig = nearfull_sig
self.linelimit = 100
self.protocolver = float5.to_str(protocol_ver)
self.protocolver = str(protocol_ver)
# playhistory -> YYYYMM/C:...
# YYYY -> 4 digit year, MM -> 2 digit month, C -> Playcount during that period

View File

@ -362,7 +362,7 @@ class AllnetConfig:
)
@property
def allow_online_updates(self) -> int:
def allow_online_updates(self) -> bool:
return CoreConfig.get_config_field(
self.__config, "core", "allnet", "allow_online_updates", default=False
)
@ -373,6 +373,12 @@ class AllnetConfig:
self.__config, "core", "allnet", "update_cfg_folder", default=""
)
@property
def save_billing(self) -> bool:
return CoreConfig.get_config_field(
self.__config, "core", "allnet", "save_billing", default=False
)
class BillingConfig:
def __init__(self, parent_config: "CoreConfig") -> None:
self.__config = parent_config

View File

@ -0,0 +1,66 @@
"""add_billing_tables
Revision ID: 27e3434740df
Revises: ae364c078429
Create Date: 2025-04-17 18:32:06.008601
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '27e3434740df'
down_revision = 'ae364c078429'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('machine_billing_charge',
sa.Column('id', sa.BIGINT(), nullable=False),
sa.Column('machine', sa.Integer(), nullable=False),
sa.Column('game_id', sa.CHAR(length=5), nullable=False),
sa.Column('game_ver', sa.FLOAT(), nullable=False),
sa.Column('play_count', sa.INTEGER(), nullable=False),
sa.Column('play_limit', sa.INTEGER(), nullable=False),
sa.Column('product_code', sa.INTEGER(), nullable=False),
sa.Column('product_count', sa.INTEGER(), nullable=False),
sa.Column('func_type', sa.INTEGER(), nullable=False),
sa.Column('player_number', sa.INTEGER(), nullable=False),
sa.ForeignKeyConstraint(['machine'], ['machine.id'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4'
)
op.create_table('machine_billing_credit',
sa.Column('id', sa.BIGINT(), nullable=False),
sa.Column('machine', sa.Integer(), nullable=False),
sa.Column('chute_type', sa.INTEGER(), nullable=False),
sa.Column('service_type', sa.INTEGER(), nullable=False),
sa.Column('operation_type', sa.INTEGER(), nullable=False),
sa.Column('coin_rate0', sa.INTEGER(), nullable=False),
sa.Column('coin_rate1', sa.INTEGER(), nullable=False),
sa.Column('coin_bonus', sa.INTEGER(), nullable=False),
sa.Column('credit_rate', sa.INTEGER(), nullable=False),
sa.Column('coin_count_slot0', sa.INTEGER(), nullable=False),
sa.Column('coin_count_slot1', sa.INTEGER(), nullable=False),
sa.Column('coin_count_slot2', sa.INTEGER(), nullable=False),
sa.Column('coin_count_slot3', sa.INTEGER(), nullable=False),
sa.Column('coin_count_slot4', sa.INTEGER(), nullable=False),
sa.Column('coin_count_slot5', sa.INTEGER(), nullable=False),
sa.Column('coin_count_slot6', sa.INTEGER(), nullable=False),
sa.Column('coin_count_slot7', sa.INTEGER(), nullable=False),
sa.ForeignKeyConstraint(['machine'], ['machine.id'], onupdate='cascade', ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('machine'),
mysql_charset='utf8mb4'
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('machine_billing_credit')
op.drop_table('machine_billing_charge')
# ### end Alembic commands ###

View File

@ -6,7 +6,7 @@ from sqlalchemy.dialects.mysql import insert
from sqlalchemy.engine import Row
from sqlalchemy.sql import func, select
from sqlalchemy.sql.schema import ForeignKey, PrimaryKeyConstraint
from sqlalchemy.types import JSON, Boolean, Integer, String
from sqlalchemy.types import JSON, Boolean, Integer, String, BIGINT, INTEGER, CHAR, FLOAT
from core.data.schema.base import BaseData, metadata
@ -67,6 +67,56 @@ arcade_owner: Table = Table(
mysql_charset="utf8mb4",
)
billing_charge: Table = Table(
"machine_billing_charge",
metadata,
Column("id", BIGINT, primary_key=True, nullable=False),
Column(
"machine",
Integer,
ForeignKey("machine.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
Column("game_id", CHAR(5), nullable=False),
Column("game_ver", FLOAT, nullable=False),
Column("play_count", INTEGER, nullable=False),
Column("play_limit", INTEGER, nullable=False),
Column("product_code", INTEGER, nullable=False),
Column("product_count", INTEGER, nullable=False),
Column("func_type", INTEGER, nullable=False),
Column("player_number", INTEGER, nullable=False),
mysql_charset="utf8mb4",
)
# These settings are only really of interest
# for real cabinets operating as pay-to-play
billing_credit: Table = Table(
"machine_billing_credit",
metadata,
Column("id", BIGINT, primary_key=True, nullable=False),
Column(
"machine",
Integer,
ForeignKey("machine.id", ondelete="cascade", onupdate="cascade"),
nullable=False, unique=True
),
Column("chute_type", INTEGER, nullable=False),
Column("service_type", INTEGER, nullable=False),
Column("operation_type", INTEGER, nullable=False),
Column("coin_rate0", INTEGER, nullable=False),
Column("coin_rate1", INTEGER, nullable=False),
Column("coin_bonus", INTEGER, nullable=False),
Column("credit_rate", INTEGER, nullable=False),
Column("coin_count_slot0", INTEGER, nullable=False),
Column("coin_count_slot1", INTEGER, nullable=False),
Column("coin_count_slot2", INTEGER, nullable=False),
Column("coin_count_slot3", INTEGER, nullable=False),
Column("coin_count_slot4", INTEGER, nullable=False),
Column("coin_count_slot5", INTEGER, nullable=False),
Column("coin_count_slot6", INTEGER, nullable=False),
Column("coin_count_slot7", INTEGER, nullable=False),
mysql_charset="utf8mb4",
)
class ArcadeData(BaseData):
async def get_machine(self, serial: Optional[str] = None, id: Optional[int] = None) -> Optional[Row]:
@ -345,6 +395,71 @@ class ArcadeData(BaseData):
return result.fetchone()['count_1']
self.logger.error("Failed to count machine serials that start with A69A!")
async def billing_add_charge(self, machine_id: int, game_id: str, game_ver: float, playcount: int, playlimit, product_code: int, product_count: int, func_type: int, player_num: int) -> Optional[int]:
result = await self.execute(billing_charge.insert().values(
machine=machine_id,
game_id=game_id,
game_ver=game_ver,
play_count=playcount,
play_limit=playlimit,
product_code=product_code,
product_count=product_count,
func_type=func_type,
player_num=player_num
))
if result is None:
self.logger.error(f"Failed to add billing charge for machine {machine_id}!")
return None
return result.lastrowid
async def billing_set_credit(self, machine_id: int, chute_type: int, service_type: int, op_mode: int, coin_rate0: int, coin_rate1: int,
bonus_adder: int, coin_to_credit_rate: int, coin_count_slot0: int, coin_count_slot1: int, coin_count_slot2: int, coin_count_slot3: int,
coin_count_slot4: int, coin_count_slot5: int, coin_count_slot6: int, coin_count_slot7: int) -> Optional[int]:
sql = insert(billing_credit).values(
machine=machine_id,
chute_type=chute_type,
service_type=service_type,
operation_type=op_mode,
coin_rate0=coin_rate0,
coin_rate1=coin_rate1,
coin_bonus=bonus_adder,
credit_rate=coin_to_credit_rate,
coin_count_slot0=coin_count_slot0,
coin_count_slot1=coin_count_slot1,
coin_count_slot2=coin_count_slot2,
coin_count_slot3=coin_count_slot3,
coin_count_slot4=coin_count_slot4,
coin_count_slot5=coin_count_slot5,
coin_count_slot6=coin_count_slot6,
coin_count_slot7=coin_count_slot7,
)
conflict = sql.on_duplicate_key_update(
chute_type=chute_type,
service_type=service_type,
operation_type=op_mode,
coin_rate0=coin_rate0,
coin_rate1=coin_rate1,
coin_bonus=bonus_adder,
credit_rate=coin_to_credit_rate,
coin_count_slot0=coin_count_slot0,
coin_count_slot1=coin_count_slot1,
coin_count_slot2=coin_count_slot2,
coin_count_slot3=coin_count_slot3,
coin_count_slot4=coin_count_slot4,
coin_count_slot5=coin_count_slot5,
coin_count_slot6=coin_count_slot6,
coin_count_slot7=coin_count_slot7,
)
result = await self.execute(conflict)
if result is None:
self.logger.error(f"Failed to set billing credit settings for machine {machine_id}!")
return None
return result.lastrowid
def format_serial(
self, platform_code: str, platform_rev: int, serial_letter: str, serial_num: int, append: int, dash: bool = False
) -> str:
@ -371,7 +486,6 @@ class ArcadeData(BaseData):
month = ((month - 1) + 9) % 12 # Offset so April=0
return f"{year:02}{month // 6:01}{month % 6 + 1:01}"
def parse_keychip_suffix(self, suffix: str) -> tuple[int, int]:
year = int(suffix[0:2])
half = int(suffix[2])

View File

@ -45,6 +45,7 @@ allnet:
loglevel: "info"
allow_online_updates: False
update_cfg_folder: ""
save_billing: True
billing:
standalone: True