feat(loyalty): wallet debug page, Google Wallet fixes, and module config env_file standardization
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 32s
CI / pytest (push) Failing after 1h13m39s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled

- Add wallet diagnostics page at /admin/loyalty/wallet-debug (super admin only)
  with explorer-sidebar pattern: config validation, class status, card inspector,
  save URL tester, recent enrollments, and Apple Wallet status panels
- Fix Google Wallet fat JWT: include both loyaltyClasses and loyaltyObjects in
  payload, use UNDER_REVIEW instead of DRAFT for class reviewStatus
- Fix StorefrontProgramResponse schema: accept google_class_id values while
  keeping exclude=True (was rejecting non-None values)
- Standardize all module configs to read from .env file directly
  (env_file=".env", extra="ignore") matching core Settings pattern
- Add MOD-026 architecture rule enforcing env_file in module configs
- Add SVC-005 noqa support in architecture validator
- Add test files for dev_tools domain_health and isolation_audit services
- Add google_wallet_status.py script for querying Google Wallet API
- Use table_wrapper macro in wallet-debug.html (FE-005 compliance)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 22:18:39 +01:00
parent 11b8e31a29
commit f89c0382f0
31 changed files with 1721 additions and 64 deletions

View File

@@ -52,6 +52,19 @@ class CardService:
.first()
)
def get_recent_cards(self, db: Session, limit: int = 20) -> list[LoyaltyCard]:
"""Get the most recently created cards with program and customer loaded."""
return (
db.query(LoyaltyCard)
.options(
joinedload(LoyaltyCard.customer),
joinedload(LoyaltyCard.program),
)
.order_by(LoyaltyCard.created_at.desc())
.limit(limit)
.all()
)
def get_card_for_update(self, db: Session, card_id: int) -> LoyaltyCard | None:
"""Get a loyalty card by ID with a row-level lock (SELECT ... FOR UPDATE).

View File

@@ -250,41 +250,8 @@ class GoogleWalletService:
if not self.is_configured:
raise GoogleWalletNotConfiguredException()
issuer_id = config.google_issuer_id
class_id = f"{issuer_id}.loyalty_program_{program.id}"
# issuerName is required by Google Wallet API
issuer_name = program.merchant.name if program.merchant else program.display_name
class_data = {
"id": class_id,
"issuerId": issuer_id,
"issuerName": issuer_name,
"reviewStatus": "DRAFT",
"programName": program.display_name,
"hexBackgroundColor": program.card_color or "#4285F4",
"localizedProgramName": {
"defaultValue": {
"language": "en",
"value": program.display_name,
},
},
}
# programLogo is required by Google Wallet API
# Google must be able to fetch the image, so it needs a public URL
logo_url = program.logo_url
if not logo_url:
logo_url = config.default_logo_url
class_data["programLogo"] = {
"sourceUri": {"uri": logo_url},
}
# Add hero image if configured
if program.hero_image_url:
class_data["heroImage"] = {
"sourceUri": {"uri": program.hero_image_url},
}
class_id = f"{config.google_issuer_id}.loyalty_program_{program.id}"
class_data = self._build_class_data(program, class_id)
try:
http = self._get_http_client()
@@ -445,15 +412,50 @@ class GoogleWalletService:
except (requests.RequestException, ValueError, AttributeError) as exc:
logger.error("Failed to update Google Wallet object: %s", exc)
def _build_class_data(
self, program: LoyaltyProgram, class_id: str,
*, review_status: str = "UNDER_REVIEW",
) -> dict[str, Any]:
"""Build the LoyaltyClass data structure."""
issuer_id = config.google_issuer_id
issuer_name = program.merchant.name if program.merchant else program.display_name
class_data: dict[str, Any] = {
"id": class_id,
"issuerId": issuer_id,
"issuerName": issuer_name,
"reviewStatus": review_status,
"programName": program.display_name,
"hexBackgroundColor": program.card_color or "#4285F4",
"localizedProgramName": {
"defaultValue": {
"language": "en",
"value": program.display_name,
},
},
}
logo_url = program.logo_url or config.default_logo_url
class_data["programLogo"] = {
"sourceUri": {"uri": logo_url},
}
if program.hero_image_url:
class_data["heroImage"] = {
"sourceUri": {"uri": program.hero_image_url},
}
return class_data
def _build_object_data(
self, card: LoyaltyCard, object_id: str
self, card: LoyaltyCard, object_id: str, class_id: str | None = None
) -> dict[str, Any]:
"""Build the LoyaltyObject data structure."""
program = card.program
object_data: dict[str, Any] = {
"id": object_id,
"classId": program.google_class_id,
"classId": class_id or program.google_class_id,
"state": "ACTIVE" if card.is_active else "INACTIVE",
"accountId": card.card_number,
"accountName": card.card_number,
@@ -537,9 +539,14 @@ class GoogleWalletService:
"loyaltyObjects": [{"id": card.google_object_id}],
}
else:
# Object not created — embed full object data in JWT
object_data = self._build_object_data(card, object_id)
# Object not created — embed full class + object data in JWT
# ("fat JWT"). Both are required for Google to render and save.
program = card.program
class_id = program.google_class_id or f"{issuer_id}.loyalty_program_{program.id}"
class_data = self._build_class_data(program, class_id)
object_data = self._build_object_data(card, object_id, class_id=class_id)
payload = {
"loyaltyClasses": [class_data],
"loyaltyObjects": [object_data],
}