From d99633345f206e454a5748ba58acbe7c01f06961 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 5 May 2026 21:04:56 +0200 Subject: [PATCH] feat(loyalty): attribute transactions to the acting POS tablet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds acting_terminal_device_id to loyalty_transactions so the audit log can distinguish between operations performed via the web terminal (human user JWT) and operations performed via a paired tablet (device JWT). The principal-of-record stays the pairing user — existing reports keep working — and this column adds "which tablet did it" alongside. Threaded through every store-API endpoint that creates a transaction (stamp add/redeem/void, points earn/redeem/void/adjust, enrollment + welcome bonus, card deactivate/reactivate). The route reads current_user.terminal_device_id, which the bearer-auth dep populates when a device JWT is presented. User-token requests leave the column NULL, as covered by the new test. Bulk admin operations (GDPR anonymization, bulk deactivate) and Celery tasks (point expiration) are not threaded — they always come from a human admin or the scheduler, never a tablet. - Migration loyalty_011 + LoyaltyTransaction.acting_terminal_device_id - 9 service signatures gain the optional kwarg - 8 store-API routes pass it through - Integration tests: device JWT populates the column, user JWT leaves it NULL Co-Authored-By: Claude Opus 4.7 (1M context) --- .../loyalty_011_add_acting_terminal_device.py | 51 +++++++++++++ .../loyalty/models/loyalty_transaction.py | 14 ++++ app/modules/loyalty/routes/api/store.py | 14 +++- app/modules/loyalty/services/card_service.py | 18 ++++- .../loyalty/services/points_service.py | 8 ++ app/modules/loyalty/services/stamp_service.py | 6 ++ .../integration/test_terminal_devices.py | 73 +++++++++++++++++++ 7 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 app/modules/loyalty/migrations/versions/loyalty_011_add_acting_terminal_device.py diff --git a/app/modules/loyalty/migrations/versions/loyalty_011_add_acting_terminal_device.py b/app/modules/loyalty/migrations/versions/loyalty_011_add_acting_terminal_device.py new file mode 100644 index 00000000..4b09c702 --- /dev/null +++ b/app/modules/loyalty/migrations/versions/loyalty_011_add_acting_terminal_device.py @@ -0,0 +1,51 @@ +"""loyalty 011 - add acting_terminal_device_id to loyalty_transactions + +Lets the audit log distinguish between actions a human user performed +directly via the web terminal and actions a paired POS tablet +performed via its device JWT. The principal-of-record stays the +pairing user (so existing reports keep working); this column adds +"which tablet did it" alongside. + +Revision ID: loyalty_011 +Revises: loyalty_010 +Create Date: 2026-05-05 +""" +import sqlalchemy as sa + +from alembic import op + +revision = "loyalty_011" +down_revision = "loyalty_010" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "loyalty_transactions", + sa.Column( + "acting_terminal_device_id", + sa.Integer(), + sa.ForeignKey( + "loyalty_terminal_devices.id", ondelete="SET NULL" + ), + nullable=True, + comment=( + "Paired POS terminal device that performed this transaction " + "(NULL when the action came from a human user via the web)" + ), + ), + ) + op.create_index( + "ix_loyalty_transactions_acting_terminal_device_id", + "loyalty_transactions", + ["acting_terminal_device_id"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_loyalty_transactions_acting_terminal_device_id", + table_name="loyalty_transactions", + ) + op.drop_column("loyalty_transactions", "acting_terminal_device_id") diff --git a/app/modules/loyalty/models/loyalty_transaction.py b/app/modules/loyalty/models/loyalty_transaction.py index 6473769d..30b3a955 100644 --- a/app/modules/loyalty/models/loyalty_transaction.py +++ b/app/modules/loyalty/models/loyalty_transaction.py @@ -105,6 +105,16 @@ class LoyaltyTransaction(Base, TimestampMixin): index=True, comment="Staff PIN used for this operation", ) + acting_terminal_device_id = Column( + Integer, + ForeignKey("loyalty_terminal_devices.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment=( + "Paired POS terminal device that performed this transaction " + "(NULL when the action came from a human user via the web)" + ), + ) category_ids = Column( JSON, nullable=True, @@ -220,6 +230,10 @@ class LoyaltyTransaction(Base, TimestampMixin): card = relationship("LoyaltyCard", back_populates="transactions") store = relationship("Store", backref="loyalty_transactions") staff_pin = relationship("StaffPin", backref="transactions") + acting_terminal_device = relationship( + "TerminalDevice", + backref="transactions", + ) related_transaction = relationship( "LoyaltyTransaction", remote_side=[id], diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index 4f0b640d..169a3203 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -703,7 +703,12 @@ def enroll_customer( merchant_id=merchant_id, ) - card = card_service.enroll_customer_for_store(db, customer_id, store_id) + card = card_service.enroll_customer_for_store( + db, + customer_id, + store_id, + acting_terminal_device_id=current_user.terminal_device_id, + ) program = card.program @@ -794,6 +799,7 @@ def add_stamp( ip_address=ip, user_agent=user_agent, notes=data.notes, + acting_terminal_device_id=current_user.terminal_device_id, ) return StampResponse(**result) @@ -821,6 +827,7 @@ def redeem_stamps( ip_address=ip, user_agent=user_agent, notes=data.notes, + acting_terminal_device_id=current_user.terminal_device_id, ) return StampRedeemResponse(**result) @@ -850,6 +857,7 @@ def void_stamps( ip_address=ip, user_agent=user_agent, notes=data.notes, + acting_terminal_device_id=current_user.terminal_device_id, ) return StampVoidResponse(**result) @@ -885,6 +893,7 @@ def earn_points( ip_address=ip, user_agent=user_agent, notes=data.notes, + acting_terminal_device_id=current_user.terminal_device_id, ) return PointsEarnResponse(**result) @@ -913,6 +922,7 @@ def redeem_points( ip_address=ip, user_agent=user_agent, notes=data.notes, + acting_terminal_device_id=current_user.terminal_device_id, ) return PointsRedeemResponse(**result) @@ -943,6 +953,7 @@ def void_points( ip_address=ip, user_agent=user_agent, notes=data.notes, + acting_terminal_device_id=current_user.terminal_device_id, ) return PointsVoidResponse(**result) @@ -973,6 +984,7 @@ def adjust_points( staff_pin=data.staff_pin, ip_address=ip, user_agent=user_agent, + acting_terminal_device_id=current_user.terminal_device_id, ) return PointsAdjustResponse(**result) diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index dd67b887..fb7cc4aa 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -520,6 +520,7 @@ class CardService: merchant_id: int, *, enrolled_at_store_id: int | None = None, + acting_terminal_device_id: int | None = None, ) -> LoyaltyCard: """ Enroll a customer in a merchant's loyalty program. @@ -594,6 +595,7 @@ class CardService: merchant_id=merchant_id, card_id=card.id, store_id=enrolled_at_store_id, + acting_terminal_device_id=acting_terminal_device_id, transaction_type=TransactionType.CARD_CREATED.value, transaction_at=datetime.now(UTC), ) @@ -607,6 +609,7 @@ class CardService: merchant_id=merchant_id, card_id=card.id, store_id=enrolled_at_store_id, + acting_terminal_device_id=acting_terminal_device_id, transaction_type=TransactionType.WELCOME_BONUS.value, points_delta=program.welcome_bonus_points, points_balance_after=card.points_balance, @@ -653,6 +656,8 @@ class CardService: db: Session, customer_id: int, store_id: int, + *, + acting_terminal_device_id: int | None = None, ) -> LoyaltyCard: """ Enroll a customer through a specific store. @@ -663,6 +668,7 @@ class CardService: db: Database session customer_id: Customer ID store_id: Store ID + acting_terminal_device_id: Paired tablet that initiated the enrollment Returns: Created loyalty card @@ -678,6 +684,7 @@ class CardService: customer_id, store.merchant_id, enrolled_at_store_id=store_id, + acting_terminal_device_id=acting_terminal_device_id, ) def deactivate_card( @@ -686,6 +693,7 @@ class CardService: card_id: int, *, store_id: int | None = None, + acting_terminal_device_id: int | None = None, ) -> LoyaltyCard: """Deactivate a loyalty card.""" card = self.require_card(db, card_id) @@ -696,6 +704,7 @@ class CardService: merchant_id=card.merchant_id, card_id=card.id, store_id=store_id, + acting_terminal_device_id=acting_terminal_device_id, transaction_type=TransactionType.CARD_DEACTIVATED.value, transaction_at=datetime.now(UTC), ) @@ -708,7 +717,13 @@ class CardService: return card - def reactivate_card(self, db: Session, card_id: int) -> LoyaltyCard: + def reactivate_card( + self, + db: Session, + card_id: int, + *, + acting_terminal_device_id: int | None = None, + ) -> LoyaltyCard: """Reactivate a deactivated loyalty card.""" card = self.require_card(db, card_id) card.is_active = True @@ -717,6 +732,7 @@ class CardService: transaction = LoyaltyTransaction( merchant_id=card.merchant_id, card_id=card.id, + acting_terminal_device_id=acting_terminal_device_id, transaction_type=TransactionType.CARD_REACTIVATED.value, transaction_at=datetime.now(UTC), ) diff --git a/app/modules/loyalty/services/points_service.py b/app/modules/loyalty/services/points_service.py index 82382a08..90c4818f 100644 --- a/app/modules/loyalty/services/points_service.py +++ b/app/modules/loyalty/services/points_service.py @@ -53,6 +53,7 @@ class PointsService: ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, + acting_terminal_device_id: int | None = None, ) -> dict: """ Earn points from a purchase. @@ -196,6 +197,7 @@ class PointsService: card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, + acting_terminal_device_id=acting_terminal_device_id, category_ids=category_ids, transaction_type=TransactionType.POINTS_EARNED.value, points_delta=points_earned, @@ -249,6 +251,7 @@ class PointsService: ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, + acting_terminal_device_id: int | None = None, ) -> dict: """ Redeem points for a reward. @@ -331,6 +334,7 @@ class PointsService: card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, + acting_terminal_device_id=acting_terminal_device_id, transaction_type=TransactionType.POINTS_REDEEMED.value, points_delta=-points_required, stamps_balance_after=card.stamp_count, @@ -385,6 +389,7 @@ class PointsService: ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, + acting_terminal_device_id: int | None = None, ) -> dict: """ Void points for a return. @@ -482,6 +487,7 @@ class PointsService: card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, + acting_terminal_device_id=acting_terminal_device_id, transaction_type=TransactionType.POINTS_VOIDED.value, points_delta=-actual_voided, stamps_balance_after=card.stamp_count, @@ -529,6 +535,7 @@ class PointsService: staff_pin: str | None = None, ip_address: str | None = None, user_agent: str | None = None, + acting_terminal_device_id: int | None = None, ) -> dict: """ Manually adjust points (admin/store operation). @@ -578,6 +585,7 @@ class PointsService: card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, + acting_terminal_device_id=acting_terminal_device_id, transaction_type=TransactionType.POINTS_ADJUSTMENT.value, points_delta=points_delta, stamps_balance_after=card.stamp_count, diff --git a/app/modules/loyalty/services/stamp_service.py b/app/modules/loyalty/services/stamp_service.py index fe183155..5842dcbb 100644 --- a/app/modules/loyalty/services/stamp_service.py +++ b/app/modules/loyalty/services/stamp_service.py @@ -50,6 +50,7 @@ class StampService: ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, + acting_terminal_device_id: int | None = None, ) -> dict: """ Add a stamp to a loyalty card. @@ -144,6 +145,7 @@ class StampService: card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, + acting_terminal_device_id=acting_terminal_device_id, category_ids=category_ids, transaction_type=TransactionType.STAMP_EARNED.value, stamps_delta=1, @@ -219,6 +221,7 @@ class StampService: ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, + acting_terminal_device_id: int | None = None, ) -> dict: """ Redeem stamps for a reward. @@ -287,6 +290,7 @@ class StampService: card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, + acting_terminal_device_id=acting_terminal_device_id, transaction_type=TransactionType.STAMP_REDEEMED.value, stamps_delta=-stamps_redeemed, stamps_balance_after=card.stamp_count, @@ -339,6 +343,7 @@ class StampService: ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, + acting_terminal_device_id: int | None = None, ) -> dict: """ Void stamps for a return. @@ -422,6 +427,7 @@ class StampService: card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, + acting_terminal_device_id=acting_terminal_device_id, transaction_type=TransactionType.STAMP_VOIDED.value, stamps_delta=-actual_voided, stamps_balance_after=card.stamp_count, diff --git a/app/modules/loyalty/tests/integration/test_terminal_devices.py b/app/modules/loyalty/tests/integration/test_terminal_devices.py index 0080161b..8c3975cb 100644 --- a/app/modules/loyalty/tests/integration/test_terminal_devices.py +++ b/app/modules/loyalty/tests/integration/test_terminal_devices.py @@ -184,3 +184,76 @@ class TestStoreAPIBearerAuth: headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code in (400, 401) + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestActingDeviceAudit: + """Transactions performed via a device JWT carry acting_terminal_device_id.""" + + def test_points_earn_via_device_stamps_audit_column( + self, client, loyalty_merchant_headers, loyalty_store_setup, db + ): + from app.modules.loyalty.models import LoyaltyTransaction + from app.modules.loyalty.models.loyalty_transaction import TransactionType + + store = loyalty_store_setup["store"] + card = loyalty_store_setup["card"] + + paired = client.post( + f"{MERCHANT_BASE}/devices", + json={"store_id": store.id, "label": "Counter 1"}, + headers=loyalty_merchant_headers, + ).json() + device_id = paired["id"] + token = paired["setup_token"] + + response = client.post( + f"{STORE_BASE}/points/earn", + json={"card_id": card.id, "purchase_amount_cents": 1500}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200, response.text + + tx = ( + db.query(LoyaltyTransaction) + .filter( + LoyaltyTransaction.card_id == card.id, + LoyaltyTransaction.transaction_type + == TransactionType.POINTS_EARNED.value, + ) + .order_by(LoyaltyTransaction.id.desc()) + .first() + ) + assert tx is not None + assert tx.acting_terminal_device_id == device_id + + def test_user_token_leaves_audit_column_null( + self, client, loyalty_store_headers, loyalty_store_setup, db + ): + """Web-terminal user JWT must NOT stamp acting_terminal_device_id.""" + from app.modules.loyalty.models import LoyaltyTransaction + from app.modules.loyalty.models.loyalty_transaction import TransactionType + + card = loyalty_store_setup["card"] + + response = client.post( + f"{STORE_BASE}/points/earn", + json={"card_id": card.id, "purchase_amount_cents": 1500}, + headers=loyalty_store_headers, + ) + assert response.status_code == 200, response.text + + tx = ( + db.query(LoyaltyTransaction) + .filter( + LoyaltyTransaction.card_id == card.id, + LoyaltyTransaction.transaction_type + == TransactionType.POINTS_EARNED.value, + ) + .order_by(LoyaltyTransaction.id.desc()) + .first() + ) + assert tx is not None + assert tx.acting_terminal_device_id is None