diff --git a/core/allnet.py b/core/allnet.py index c878870..2cb823e 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -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 diff --git a/core/config.py b/core/config.py index e05323b..eb02c4e 100644 --- a/core/config.py +++ b/core/config.py @@ -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 diff --git a/core/data/alembic/versions/27e3434740df_add_billing_tables.py b/core/data/alembic/versions/27e3434740df_add_billing_tables.py new file mode 100644 index 0000000..191336b --- /dev/null +++ b/core/data/alembic/versions/27e3434740df_add_billing_tables.py @@ -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 ### diff --git a/core/data/schema/arcade.py b/core/data/schema/arcade.py index 038077d..cfbff96 100644 --- a/core/data/schema/arcade.py +++ b/core/data/schema/arcade.py @@ -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]) diff --git a/example_config/core.yaml b/example_config/core.yaml index 0f047f0..fa04a67 100644 --- a/example_config/core.yaml +++ b/example_config/core.yaml @@ -45,6 +45,7 @@ allnet: loglevel: "info" allow_online_updates: False update_cfg_folder: "" + save_billing: True billing: standalone: True