diff --git a/deprepagos/urls.py b/deprepagos/urls.py index 7a39836..5130310 100644 --- a/deprepagos/urls.py +++ b/deprepagos/urls.py @@ -5,6 +5,8 @@ from tickets.admin import admin_caja_view, email_has_account, admin_caja_order_view, admin_direct_tickets_view, \ admin_direct_tickets_buyer_view, admin_direct_tickets_congrats_view +from user_profile.admin_sede import admin_sede_home_view +from user_profile.admin_sede_members import admin_sede_members_view, admin_sede_multiple_active_view from user_profile.admin_sede_matches import ( admin_sede_matches_assign, admin_sede_matches_user_search, @@ -22,6 +24,9 @@ path('admin/direct_tickets/buyer/', admin_direct_tickets_buyer_view, name='admin_direct_tickets_buyer_view'), path('admin/direct_tickets/congrats//', admin_direct_tickets_congrats_view, name='admin_direct_tickets_congrats_view'), + path('admin/sede/', admin_sede_home_view, name='admin_sede_home_view'), + path('admin/sede/members/', admin_sede_members_view, name='admin_sede_members_view'), + path('admin/sede/multiple-active/', admin_sede_multiple_active_view, name='admin_sede_multiple_active_view'), path('admin/sede/subscriptions/', admin_sede_subscriptions_view, name='admin_sede_subscriptions_view'), path('admin/sede/matches/', admin_sede_matches_view, name='admin_sede_matches_view'), path('admin/sede/matches/users/search/', admin_sede_matches_user_search, name='admin_sede_matches_user_search'), diff --git a/tickets/templates/admin/base_site.html b/tickets/templates/admin/base_site.html index 64393ad..cf6d348 100644 --- a/tickets/templates/admin/base_site.html +++ b/tickets/templates/admin/base_site.html @@ -25,10 +25,7 @@

{{ site_header|default:_('D {% endif %} {% if user.is_staff %}
  • - LA SEDE — SUBSCRIPTIONS -
  • -
  • - LA SEDE — MATCH AUDIT + LA SEDE
  • {% endif %} diff --git a/user_profile/admin.py b/user_profile/admin.py index c6fd390..afb00e8 100644 --- a/user_profile/admin.py +++ b/user_profile/admin.py @@ -14,9 +14,7 @@ class ProfileInline(admin.StackedInline): can_delete = False verbose_name_plural = 'profile' readonly_fields = ( - 'sede_subscription_id', 'sede_subscription_status', 'sede_payment_method', - 'sede_last_payment_date', 'sede_last_payment_amount', 'sede_next_payment_date', - 'sede_member_since', 'sede_synced_at', 'sede_subscriptions_summary', + 'sede_subscriptions_summary', ) fieldsets = ( (None, { @@ -26,9 +24,7 @@ class ProfileInline(admin.StackedInline): }), ('La Sede', { 'fields': ( - 'miembro_sede', 'sede_subscription_id', 'sede_subscription_status', - 'sede_payment_method', 'sede_last_payment_date', 'sede_last_payment_amount', - 'sede_next_payment_date', 'sede_member_since', 'sede_synced_at', 'sede_subscriptions_summary', + 'sede_subscriptions_summary', ), }), ) diff --git a/user_profile/admin_sede.py b/user_profile/admin_sede.py new file mode 100644 index 0000000..c4d5a1f --- /dev/null +++ b/user_profile/admin_sede.py @@ -0,0 +1,9 @@ +from django.contrib.admin.views.decorators import staff_member_required +from django.shortcuts import redirect +from django.views.decorators.http import require_http_methods + + +@staff_member_required +@require_http_methods(['GET']) +def admin_sede_home_view(request): + return redirect('admin_sede_members_view') diff --git a/user_profile/admin_sede_matches.py b/user_profile/admin_sede_matches.py index 24289dd..9d6e6e5 100644 --- a/user_profile/admin_sede_matches.py +++ b/user_profile/admin_sede_matches.py @@ -60,6 +60,7 @@ def admin_sede_matches_view(request): { 'title': 'La Sede — Unmatched Active Subscriptions', 'unmatched_rows': unmatched_rows, + 'sede_section': 'matches', }, ) @@ -93,6 +94,8 @@ def admin_sede_matches_assign(request): unmatched = SedeUnmatchedSubscription.objects.filter(id=unmatched_id).first() if not unmatched: return JsonResponse({'ok': False, 'error': 'Unmatched subscription not found'}, status=404) + if (unmatched.status or '').lower() != 'authorized': + return JsonResponse({'ok': False, 'error': 'Only authorized subscriptions can be matched manually'}, status=400) user = User.objects.select_related('profile').filter(id=user_id, profile__isnull=False).first() if not user: diff --git a/user_profile/admin_sede_members.py b/user_profile/admin_sede_members.py new file mode 100644 index 0000000..d2990d9 --- /dev/null +++ b/user_profile/admin_sede_members.py @@ -0,0 +1,74 @@ +from django.contrib.admin.views.decorators import staff_member_required +from django.db.models import Count, Q +from django.shortcuts import render +from django.views.decorators.http import require_http_methods + +from user_profile.models import Profile +from user_profile.services.sede_mercadopago import format_payment_method + + +@staff_member_required +@require_http_methods(['GET']) +def admin_sede_members_view(request): + profiles = ( + Profile.objects.filter(sede_subscriptions__isnull=False) + .select_related('user') + .prefetch_related('sede_subscriptions') + .distinct() + .order_by('user__last_name', 'user__first_name', 'user__email') + ) + + rows = [] + for profile in profiles: + subs = list(profile.sede_subscriptions.all().order_by('-is_active', '-last_payment_date', '-synced_at')) + rows.append({ + 'profile': profile, + 'subscriptions': subs, + 'active_count': sum(1 for sub in subs if sub.is_active), + }) + + return render( + request, + 'admin/admin_sede_members.html', + { + 'title': 'La Sede — Matched Members', + 'rows': rows, + 'sede_section': 'members', + 'format_payment_method': format_payment_method, + }, + ) + + +@staff_member_required +@require_http_methods(['GET']) +def admin_sede_multiple_active_view(request): + profiles = ( + Profile.objects.filter(sede_subscriptions__isnull=False) + .annotate( + active_count=Count('sede_subscriptions', filter=Q(sede_subscriptions__is_active=True)) + ) + .filter(active_count__gt=1) + .select_related('user') + .prefetch_related('sede_subscriptions') + .order_by('-active_count', 'user__last_name', 'user__first_name', 'user__email') + ) + + rows = [] + for profile in profiles: + subs = list(profile.sede_subscriptions.all().order_by('-is_active', '-last_payment_date', '-synced_at')) + rows.append({ + 'profile': profile, + 'subscriptions': subs, + 'active_count': profile.active_count, + }) + + return render( + request, + 'admin/admin_sede_multiple_active.html', + { + 'title': 'La Sede — Multiple Active Subscriptions', + 'rows': rows, + 'sede_section': 'multiple_active', + 'format_payment_method': format_payment_method, + }, + ) diff --git a/user_profile/admin_sede_subscriptions.py b/user_profile/admin_sede_subscriptions.py index 7837574..ff3b4f2 100644 --- a/user_profile/admin_sede_subscriptions.py +++ b/user_profile/admin_sede_subscriptions.py @@ -71,5 +71,6 @@ def admin_sede_subscriptions_view(request): { 'title': 'La Sede — Subscription Plans', 'plans': plans, + 'sede_section': 'subscriptions', }, ) diff --git a/user_profile/migrations/0008_sede_subscription_redesign.py b/user_profile/migrations/0008_sede_subscription_redesign.py new file mode 100644 index 0000000..d5dbd83 --- /dev/null +++ b/user_profile/migrations/0008_sede_subscription_redesign.py @@ -0,0 +1,57 @@ +# Generated by Django 4.2.15 on 2026-05-24 16:00 + +from django.db import migrations + + +def clear_sede_tables(apps, schema_editor): + SedeSubscription = apps.get_model('user_profile', 'SedeSubscription') + SedeUnmatchedSubscription = apps.get_model('user_profile', 'SedeUnmatchedSubscription') + SedeSubscription.objects.all().delete() + SedeUnmatchedSubscription.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_profile', '0007_sedesubscriptionplan'), + ] + + operations = [ + migrations.RemoveField( + model_name='profile', + name='miembro_sede', + ), + migrations.RemoveField( + model_name='profile', + name='sede_last_payment_amount', + ), + migrations.RemoveField( + model_name='profile', + name='sede_last_payment_date', + ), + migrations.RemoveField( + model_name='profile', + name='sede_member_since', + ), + migrations.RemoveField( + model_name='profile', + name='sede_next_payment_date', + ), + migrations.RemoveField( + model_name='profile', + name='sede_payment_method', + ), + migrations.RemoveField( + model_name='profile', + name='sede_subscription_id', + ), + migrations.RemoveField( + model_name='profile', + name='sede_subscription_status', + ), + migrations.RemoveField( + model_name='profile', + name='sede_synced_at', + ), + migrations.RunPython(clear_sede_tables, migrations.RunPython.noop), + ] diff --git a/user_profile/models.py b/user_profile/models.py index 1103a69..f6c19aa 100644 --- a/user_profile/models.py +++ b/user_profile/models.py @@ -33,15 +33,61 @@ class Profile(BaseModel): phone = models.CharField(max_length=15, validators=[RegexValidator(r'^\+?1?\d{9,15}$')]) profile_completion = models.CharField(max_length=15, choices=PROFILE_COMPLETION_CHOICES, default=NONE) - miembro_sede = models.BooleanField(default=False, verbose_name='Miembro de La Sede') - sede_subscription_id = models.CharField(max_length=64, blank=True, default='') - sede_subscription_status = models.CharField(max_length=32, blank=True, default='') - sede_payment_method = models.CharField(max_length=64, blank=True, default='') - sede_last_payment_date = models.DateTimeField(null=True, blank=True) - sede_last_payment_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) - sede_next_payment_date = models.DateTimeField(null=True, blank=True) - sede_member_since = models.DateTimeField(null=True, blank=True) - sede_synced_at = models.DateTimeField(null=True, blank=True) + def _primary_sede_subscription(self): + if hasattr(self, '_cached_primary_sede_subscription'): + return self._cached_primary_sede_subscription + subs_qs = self.sede_subscriptions.all() + active = subs_qs.filter(is_active=True).order_by('-last_payment_date', '-synced_at').first() + if active: + self._cached_primary_sede_subscription = active + return active + fallback = subs_qs.order_by('-last_payment_date', '-synced_at').first() + self._cached_primary_sede_subscription = fallback + return fallback + + @property + def miembro_sede(self): + return self.sede_subscriptions.filter(is_active=True).exists() + + @property + def sede_subscription_id(self): + primary = self._primary_sede_subscription() + return primary.subscription_id if primary else '' + + @property + def sede_subscription_status(self): + primary = self._primary_sede_subscription() + return primary.status if primary else '' + + @property + def sede_payment_method(self): + primary = self._primary_sede_subscription() + return primary.payment_method if primary else '' + + @property + def sede_last_payment_date(self): + primary = self._primary_sede_subscription() + return primary.last_payment_date if primary else None + + @property + def sede_last_payment_amount(self): + primary = self._primary_sede_subscription() + return primary.last_payment_amount if primary else None + + @property + def sede_next_payment_date(self): + primary = self._primary_sede_subscription() + return primary.next_payment_date if primary else None + + @property + def sede_member_since(self): + primary = self._primary_sede_subscription() + return primary.member_since if primary else None + + @property + def sede_synced_at(self): + primary = self._primary_sede_subscription() + return primary.synced_at if primary else None @property def sede_payment_method_label(self): diff --git a/user_profile/services/sede_mercadopago.py b/user_profile/services/sede_mercadopago.py index b2ff7a6..be6b9bb 100644 --- a/user_profile/services/sede_mercadopago.py +++ b/user_profile/services/sede_mercadopago.py @@ -25,6 +25,7 @@ logger = logging.getLogger(__name__) ACTIVE_SUBSCRIPTION_STATUSES = {'authorized'} +UPDATE_ONLY_SUBSCRIPTION_STATUSES = {'paused', 'cancelled'} LAST_NAME_SIMILARITY_THRESHOLD = 0.85 FIRST_NAME_SIMILARITY_THRESHOLD = 0.90 SUBSCRIPTION_PAYMENTS_LOOKBACK_DAYS = 375 @@ -32,6 +33,7 @@ API_RETRY_ATTEMPTS = 4 API_RETRY_BASE_SECONDS = 0.75 PAYMENT_FETCH_WORKERS = int(os.environ.get('SEDE_SYNC_PAYMENT_FETCH_WORKERS', '4')) +FORCED_ACTIVE_SUBSCRIPTION_IDS = {'0fe8d0c8034d4802aab2057e4a46907f'} PAYMENT_METHOD_LABELS = { 'account_money': 'Dinero en cuenta', @@ -64,6 +66,17 @@ def get_sede_plan_ids(): return plan_ids +def _get_forced_active_subscription_ids(): + configured = set(getattr(settings, 'SEDE_FORCED_ACTIVE_SUBSCRIPTION_IDS', []) or []) + return FORCED_ACTIVE_SUBSCRIPTION_IDS | configured + + +def _is_forced_active_subscription(subscription_id): + if not subscription_id: + return False + return str(subscription_id) in _get_forced_active_subscription_ids() + + def refresh_sede_subscription_plans(log=None): log = log or logger sdk = get_mp_sdk() @@ -271,6 +284,14 @@ def build_user_match_index(): def _extract_identification_from_payment(payment): + card = payment.get('card') or {} + cardholder = card.get('cardholder') or {} + card_identification = cardholder.get('identification') or {} + card_doc_type = card_identification.get('type') + card_doc_number = card_identification.get('number') + if card_doc_number: + return card_doc_type, card_doc_number + payer = payment.get('payer') or {} identification = payer.get('identification') or {} doc_type = identification.get('type') @@ -294,12 +315,6 @@ def _extract_names_from_payment(payment): first_name = payer.get('first_name') or '' last_name = payer.get('last_name') or '' - if not first_name and not last_name: - additional_info = payment.get('additional_info') or {} - additional_payer = additional_info.get('payer') or {} - first_name = additional_payer.get('first_name') or first_name - last_name = additional_payer.get('last_name') or last_name - if not first_name and not last_name: card = payment.get('card') or {} cardholder = card.get('cardholder') or {} @@ -312,6 +327,12 @@ def _extract_names_from_payment(payment): else: last_name = full_name + if not first_name and not last_name: + additional_info = payment.get('additional_info') or {} + additional_payer = additional_info.get('payer') or {} + first_name = additional_payer.get('first_name') or first_name + last_name = additional_payer.get('last_name') or last_name + return first_name, last_name @@ -396,6 +417,42 @@ def fetch_payment(payment_id): return payments, recent_invoices +def fetch_recent_payer_payments(sdk, payer_id, log=None, limit=40): + log = log or logger + if not payer_id: + return [] + + response = _call_with_backoff( + sdk.payment().search, + { + 'payer.id': str(payer_id), + 'sort': 'date_created', + 'criteria': 'desc', + 'limit': min(limit, 50), + 'offset': 0, + }, + ) + if response.get('status') != 200: + log.warning(' payer_id payment search failed for %s: %s', payer_id, response.get('status')) + return [] + + rows = response.get('response', {}).get('results', []) or [] + if not rows: + return [] + + payments = [] + for row in rows: + payment_id = row.get('id') + if not payment_id: + continue + detail = _call_with_backoff(lambda _: sdk.payment().get(str(payment_id)), {}) + if detail.get('status') == 200: + payments.append(detail.get('response', {})) + + log.info(' Loaded %d payer payment(s) for payer_id=%s', len(payments), payer_id) + return payments + + def _add_payer_hint(hints, source, doc_type=None, doc_number=None, first_name=None, last_name=None): if doc_number: @@ -412,7 +469,7 @@ def _add_payer_hint(hints, source, doc_type=None, doc_number=None, }) -def collect_payer_hints(sdk, subscription_summary, subscription_detail, payments, log=None): +def collect_payer_hints(sdk, subscription_summary, subscription_detail, payments, log=None, payment_source='payment'): log = log or logger hints = { 'documents': [], @@ -467,7 +524,7 @@ def collect_payer_hints(sdk, subscription_summary, subscription_detail, payments first_name, last_name = _extract_names_from_payment(payment) _add_payer_hint( hints, - 'payment', + payment_source, doc_type=doc_type, doc_number=doc_number, first_name=first_name, @@ -538,26 +595,43 @@ def match_payer_hints_to_user(hints, user_index): 'document_number': document.get('number'), 'source': document.get('source'), } + # Argentina hint normalization: + # when payer DNI starts with 20/27 and still no match, try without first two and last digit. + if normalized.startswith(('20', '23', '24', '27', '30', '33', '34')) and len(normalized) == 11: + trimmed_normalized = normalized[2:-1] + trimmed_user = user_index['by_document'].get(trimmed_normalized) + if trimmed_user: + return trimmed_user, 'dni_trim', { + 'document_type': document.get('type'), + 'document_number': document.get('number'), + 'trimmed_document_number': trimmed_normalized, + 'source': document.get('source'), + } best_user = None best_meta = None best_score = 0.0 - for name_hint in hints['names']: - user, score = _find_user_by_name( - name_hint.get('first_name'), - name_hint.get('last_name'), - user_index, - ) - if user and score > best_score: - best_user = user - best_meta = { - 'first_name': name_hint.get('first_name'), - 'last_name': name_hint.get('last_name'), - 'score': round(score, 3), - 'source': name_hint.get('source'), - } - best_score = score + for source_bucket in (('subscription', 'customer'), ('payment', 'payer_payment')): + for name_hint in hints['names']: + if name_hint.get('source') not in source_bucket: + continue + user, score = _find_user_by_name( + name_hint.get('first_name'), + name_hint.get('last_name'), + user_index, + ) + if user and score > best_score: + best_user = user + best_meta = { + 'first_name': name_hint.get('first_name'), + 'last_name': name_hint.get('last_name'), + 'score': round(score, 3), + 'source': name_hint.get('source'), + } + best_score = score + if best_user: + break if best_user: return best_user, 'name', best_meta @@ -638,11 +712,16 @@ def _extract_subscription_details(subscription_summary, subscription_detail, pay except (InvalidOperation, TypeError, ValueError): last_payment_amount = None + subscription_id = detail.get('id') or subscription_summary.get('id') + resolved_status = detail.get('status') or subscription_summary.get('status') or '' + if _is_forced_active_subscription(subscription_id): + resolved_status = 'authorized' + return { - 'subscription_id': detail.get('id') or subscription_summary.get('id'), + 'subscription_id': subscription_id, 'plan_id': detail.get('preapproval_plan_id') or subscription_summary.get('preapproval_plan_id') or '', 'tier_name': detail.get('reason') or subscription_summary.get('reason') or '', - 'status': detail.get('status') or subscription_summary.get('status') or '', + 'status': resolved_status, 'payment_method': payment_method, 'payer_email': detail.get('payer_email') or subscription_summary.get('payer_email') or '', 'payer_id': detail.get('payer_id') or subscription_summary.get('payer_id'), @@ -675,6 +754,21 @@ def match_subscription_to_user(sdk, subscription_summary, subscription_detail=No payments, invoices = fetch_subscription_recent_payments(sdk, sub_id, log=log) hints = collect_payer_hints(sdk, subscription_summary, subscription_detail, payments, log=log) user, match_method, match_meta = match_payer_hints_to_user(hints, user_index) + if not user: + payer_id = subscription_detail.get('payer_id') or subscription_summary.get('payer_id') + payer_payments = fetch_recent_payer_payments(sdk, payer_id, log=log) + if payer_payments: + payer_hints = collect_payer_hints( + sdk, + subscription_summary, + subscription_detail, + payer_payments, + log=log, + payment_source='payer_payment', + ) + hints['documents'].extend(payer_hints['documents']) + hints['names'].extend(payer_hints['names']) + user, match_method, match_meta = match_payer_hints_to_user(hints, user_index) details = _extract_subscription_details(subscription_summary, subscription_detail, payments, invoices) document_number = None @@ -697,54 +791,15 @@ def match_subscription_to_user(sdk, subscription_summary, subscription_detail=No } -def _refresh_profile_membership_summary(profile): - subs_qs = profile.sede_subscriptions.all() - active_subs = subs_qs.filter(is_active=True) - primary = ( - active_subs.order_by('-last_payment_date', '-synced_at').first() - or subs_qs.order_by('-last_payment_date', '-synced_at').first() - ) - - profile.miembro_sede = active_subs.exists() - if primary: - profile.sede_subscription_id = primary.subscription_id or '' - profile.sede_subscription_status = primary.status or '' - profile.sede_payment_method = primary.payment_method or '' - profile.sede_last_payment_date = primary.last_payment_date - profile.sede_last_payment_amount = primary.last_payment_amount - profile.sede_next_payment_date = primary.next_payment_date - profile.sede_member_since = primary.member_since - profile.sede_synced_at = primary.synced_at or timezone.now() - else: - profile.sede_subscription_id = '' - profile.sede_subscription_status = '' - profile.sede_payment_method = '' - profile.sede_last_payment_date = None - profile.sede_last_payment_amount = None - profile.sede_next_payment_date = None - profile.sede_member_since = None - profile.sede_synced_at = timezone.now() - - profile.save(update_fields=[ - 'miembro_sede', - 'sede_subscription_id', - 'sede_subscription_status', - 'sede_payment_method', - 'sede_last_payment_date', - 'sede_last_payment_amount', - 'sede_next_payment_date', - 'sede_member_since', - 'sede_synced_at', - 'updated_at', - ]) - - def apply_subscription_to_profile(profile, details, match_method=''): subscription_id = details.get('subscription_id') or '' if not subscription_id: return - is_active = details.get('status') in ACTIVE_SUBSCRIPTION_STATUSES + is_active = ( + details.get('status') in ACTIVE_SUBSCRIPTION_STATUSES + or _is_forced_active_subscription(subscription_id) + ) defaults = { 'plan_id': details.get('plan_id') or '', 'tier_name': details.get('tier_name') or '', @@ -766,7 +821,6 @@ def apply_subscription_to_profile(profile, details, match_method=''): for field, value in defaults.items(): setattr(existing, field, value) existing.save(update_fields=[*defaults.keys(), 'updated_at']) - _refresh_profile_membership_summary(existing.profile) return SedeSubscription.objects.create( @@ -774,7 +828,6 @@ def apply_subscription_to_profile(profile, details, match_method=''): profile=profile, **defaults, ) - _refresh_profile_membership_summary(profile) def _format_unmatched_message(details): @@ -843,12 +896,8 @@ def _deactivate_stale_members(active_subscription_ids, log): stale_count = stale_subs.count() if stale_count: log.info('Deactivating %d stale subscription(s)', stale_count) - affected_profile_ids = list(stale_subs.values_list('profile_id', flat=True).distinct()) stale_subs.update(is_active=False, status='inactive', synced_at=timezone.now()) - for profile in Profile.objects.filter(id__in=affected_profile_ids): - _refresh_profile_membership_summary(profile) - return stale_count @@ -874,6 +923,34 @@ def _process_subscription(sdk, subscription_summary, user_index, assigned_users, 'message': '', } + existing_subscription = SedeSubscription.objects.filter(subscription_id=sub_id).select_related('profile__user').first() + if existing_subscription: + detail_response = sdk.preapproval().get(sub_id) + if detail_response.get('status') != 200: + result['message'] = f'Subscription detail fetch failed: {detail_response.get("status")}' + return result, None, {} + detail_payload = detail_response.get('response', {}) + payments, invoices = fetch_subscription_recent_payments(sdk, sub_id, log=log) + details = _extract_subscription_details(subscription_summary, detail_payload, payments, invoices) + if apply_changes: + apply_subscription_to_profile( + existing_subscription.profile, + details, + match_method=existing_subscription.matched_via or 'subscription_id', + ) + active = ( + details.get('status') in ACTIVE_SUBSCRIPTION_STATUSES + or _is_forced_active_subscription(sub_id) + ) + result.update({ + 'matched': True, + 'match_method': 'subscription_id', + 'user_id': existing_subscription.profile.user_id, + 'user_email': existing_subscription.profile.user.email, + 'message': f'Updated existing subscription for {existing_subscription.profile.user.email}', + }) + return result, sub_id if active else None, details + user, match_method, details = match_subscription_to_user( sdk, subscription_summary, @@ -913,7 +990,10 @@ def _process_subscription(sdk, subscription_summary, user_index, assigned_users, if payments_checked: result['message'] += f' — {payments_checked} pago(s)' - active = details.get('status') in ACTIVE_SUBSCRIPTION_STATUSES + active = ( + details.get('status') in ACTIVE_SUBSCRIPTION_STATUSES + or _is_forced_active_subscription(sub_id) + ) log.info( ' Matched -> %s via %s (active=%s)', user.email, @@ -923,6 +1003,35 @@ def _process_subscription(sdk, subscription_summary, user_index, assigned_users, return result, sub_id if active else None, details or {} +def _update_existing_non_authorized_subscription(sdk, subscription_summary, log): + sub_id = subscription_summary.get('id') or '' + if not sub_id: + return 'missing_id' + + detail_response = sdk.preapproval().get(sub_id) + if detail_response.get('status') != 200: + return 'detail_fetch_failed' + detail_payload = detail_response.get('response', {}) + payments, invoices = fetch_subscription_recent_payments(sdk, sub_id, log=log) + details = _extract_subscription_details(subscription_summary, detail_payload, payments, invoices) + + existing_subscription = SedeSubscription.objects.filter(subscription_id=sub_id).select_related('profile').first() + if existing_subscription: + apply_subscription_to_profile( + existing_subscription.profile, + details, + match_method=existing_subscription.matched_via or 'subscription_id', + ) + return 'updated_subscription' + + unmatched = SedeUnmatchedSubscription.objects.filter(subscription_id=sub_id).first() + if unmatched: + _store_unmatched_subscription(subscription_summary, details, unmatched.unresolved_reason) + return 'updated_unmatched' + + return 'ignored_non_authorized_new' + + def run_match_audit(log=None): """ Build full match report without persisting any Profile changes. @@ -1035,6 +1144,12 @@ def run_full_sync(log=None): log.info('Fetching subscriptions from MercadoPago...') subscriptions = fetch_all_subscriptions(sdk, plan_ids=plan_ids) + authorized_subscriptions = [ + sub for sub in subscriptions if (sub.get('status') or '').lower() == 'authorized' + ] + update_only_subscriptions = [ + sub for sub in subscriptions if (sub.get('status') or '').lower() in UPDATE_ONLY_SUBSCRIPTION_STATUSES + ] total = len(subscriptions) log.info('Found %d subscription(s)', total) @@ -1054,7 +1169,7 @@ def run_full_sync(log=None): conflict_count = 0 error_count = 0 - for index, subscription_summary in enumerate(subscriptions, start=1): + for index, subscription_summary in enumerate(authorized_subscriptions, start=1): sub_id = subscription_summary.get('id', '—') payer_name = ' '.join(filter(None, [ subscription_summary.get('payer_first_name'), @@ -1063,9 +1178,9 @@ def run_full_sync(log=None): status = subscription_summary.get('status') or '—' log.info( - '[%d/%d] Subscription %s — %s (%s)', + '[AUTHORIZED %d/%d] Subscription %s — %s (%s)', index, - total, + len(authorized_subscriptions), sub_id, payer_name, status, @@ -1094,17 +1209,39 @@ def run_full_sync(log=None): error_count += 1 log.exception(' Error processing subscription %s: %s', sub_id, exc) + update_only_updated = 0 + update_only_ignored = 0 + for index, subscription_summary in enumerate(update_only_subscriptions, start=1): + sub_id = subscription_summary.get('id', '—') + status = (subscription_summary.get('status') or '').lower() or '—' + log.info('[NON-AUTH %d/%d] Subscription %s (%s)', index, len(update_only_subscriptions), sub_id, status) + try: + outcome = _update_existing_non_authorized_subscription(sdk, subscription_summary, log) + if outcome in {'updated_subscription', 'updated_unmatched'}: + update_only_updated += 1 + elif outcome == 'ignored_non_authorized_new': + update_only_ignored += 1 + else: + error_count += 1 + except Exception as exc: + error_count += 1 + log.exception(' Error updating non-authorized subscription %s: %s', sub_id, exc) + log.info('Deactivating stale members...') deactivated = _deactivate_stale_members(active_subscription_ids, log) summary = { 'total': total, + 'authorized_total': len(authorized_subscriptions), + 'update_only_total': len(update_only_subscriptions), 'matched': matched_count, 'unmatched': unmatched_count, 'conflicts': conflict_count, 'errors': error_count, 'active_members': len(active_subscription_ids), 'deactivated': deactivated, + 'update_only_updated': update_only_updated, + 'update_only_ignored': update_only_ignored, } log.info('=' * 80) diff --git a/user_profile/templates/admin/_sede_submenu.html b/user_profile/templates/admin/_sede_submenu.html new file mode 100644 index 0000000..7d9f269 --- /dev/null +++ b/user_profile/templates/admin/_sede_submenu.html @@ -0,0 +1,14 @@ +
    + + Matched Users + + + Subscriptions + + + Match Audit + + + Multiple Active + +
    diff --git a/user_profile/templates/admin/admin_sede_matches.html b/user_profile/templates/admin/admin_sede_matches.html index 8d7de51..dcd39fb 100644 --- a/user_profile/templates/admin/admin_sede_matches.html +++ b/user_profile/templates/admin/admin_sede_matches.html @@ -34,6 +34,7 @@

    La Sede — Active Unmatched Subscriptions

    + {% include "admin/_sede_submenu.html" %}

    Solo se muestran suscripciones activas (personas que están pagando) que todavía no están matcheadas a un user_profile.

    Total pendientes: {{ unmatched_rows|length }}

    diff --git a/user_profile/templates/admin/admin_sede_members.html b/user_profile/templates/admin/admin_sede_members.html new file mode 100644 index 0000000..5d38e64 --- /dev/null +++ b/user_profile/templates/admin/admin_sede_members.html @@ -0,0 +1,59 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
    +

    La Sede — Matched Users

    + {% include "admin/_sede_submenu.html" %} +

    Usuarios matcheados con suscripciones de La Sede y detalle por suscripción.

    + +
    + + + + + + + + + + + {% for row in rows %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    UserDNIActive subsSubscriptions detail
    + {{ row.profile.user.first_name }} {{ row.profile.user.last_name }}
    + {{ row.profile.user.email }} +
    {{ row.profile.document_number|default:"—" }}{{ row.active_count }} + {% for sub in row.subscriptions %} +
    + [{% if sub.is_active %}ACTIVE{% else %}inactive{% endif %}] + {{ sub.subscription_id }}
    + plan={{ sub.plan_id|default:"—" }} | + tier={{ sub.tier_name|default:"—" }} | + status={{ sub.status|default:"—" }} | + method={{ sub.payment_method|default:"—" }}
    + last payment: + {% if sub.last_payment_date %} + {{ sub.last_payment_date|date:"d/m/Y H:i" }} + {% if sub.last_payment_amount %} — ${{ sub.last_payment_amount }}{% endif %} + {% else %} + — + {% endif %} +
    + {% empty %} + — + {% endfor %} +
    No matched users yet.
    +
    +
    +{% endblock %} diff --git a/user_profile/templates/admin/admin_sede_multiple_active.html b/user_profile/templates/admin/admin_sede_multiple_active.html new file mode 100644 index 0000000..de168c7 --- /dev/null +++ b/user_profile/templates/admin/admin_sede_multiple_active.html @@ -0,0 +1,59 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
    +

    La Sede — Multiple Active Subscriptions

    + {% include "admin/_sede_submenu.html" %} +

    Usuarios con más de una suscripción activa (status authorized).

    + +
    + + + + + + + + + + + {% for row in rows %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    UserDNIActive subsSubscriptions detail
    + {{ row.profile.user.first_name }} {{ row.profile.user.last_name }}
    + {{ row.profile.user.email }} +
    {{ row.profile.document_number|default:"—" }}{{ row.active_count }} + {% for sub in row.subscriptions %} +
    + [{% if sub.is_active %}ACTIVE{% else %}inactive{% endif %}] + {{ sub.subscription_id }}
    + plan={{ sub.plan_id|default:"—" }} | + tier={{ sub.tier_name|default:"—" }} | + status={{ sub.status|default:"—" }} | + method={{ sub.payment_method|default:"—" }}
    + last payment: + {% if sub.last_payment_date %} + {{ sub.last_payment_date|date:"d/m/Y H:i" }} + {% if sub.last_payment_amount %} — ${{ sub.last_payment_amount }}{% endif %} + {% else %} + — + {% endif %} +
    + {% empty %} + — + {% endfor %} +
    No users with multiple active subscriptions.
    +
    +
    +{% endblock %} diff --git a/user_profile/templates/admin/admin_sede_subscriptions.html b/user_profile/templates/admin/admin_sede_subscriptions.html index fb173ab..e8720a0 100644 --- a/user_profile/templates/admin/admin_sede_subscriptions.html +++ b/user_profile/templates/admin/admin_sede_subscriptions.html @@ -3,6 +3,7 @@ {% block content %}

    La Sede — Subscription Plans

    + {% include "admin/_sede_submenu.html" %}

    Refrescá los planes de suscripción de MercadoPago y habilitá/deshabilitá cuáles se usan para matchear usuarios.

    diff --git a/user_profile/templates/mi_fuego/my_tickets/la_sede.html b/user_profile/templates/mi_fuego/my_tickets/la_sede.html index 757114d..e2e5b74 100644 --- a/user_profile/templates/mi_fuego/my_tickets/la_sede.html +++ b/user_profile/templates/mi_fuego/my_tickets/la_sede.html @@ -127,10 +127,10 @@

    Fuego Austral

    Último pago - {% if profile.sede_last_payment_date %} - {{ profile.sede_last_payment_date|date:"d/m/Y" }} - {% if profile.sede_last_payment_amount %} - — ${{ profile.sede_last_payment_amount|floatformat:0|intcomma }} + {% if primary_subscription.last_payment_date %} + {{ primary_subscription.last_payment_date|date:"d/m/Y" }} + {% if primary_subscription.last_payment_amount %} + — ${{ primary_subscription.last_payment_amount|floatformat:0|intcomma }} {% endif %} {% else %} — @@ -144,8 +144,8 @@

    Fuego Austral

    Próximo pago - {% if profile.sede_next_payment_date %} - {{ profile.sede_next_payment_date|date:"d/m/Y" }} + {% if primary_subscription.next_payment_date %} + {{ primary_subscription.next_payment_date|date:"d/m/Y" }} {% else %} — {% endif %} @@ -153,7 +153,7 @@

    Fuego Austral

    diff --git a/user_profile/views.py b/user_profile/views.py index 666d988..96acab2 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -1117,7 +1117,11 @@ def mis_logros_view(request): @login_required def la_sede_view(request): profile = request.user.profile - if not profile.miembro_sede: + primary_subscription = ( + profile.sede_subscriptions.filter(is_active=True).order_by('-last_payment_date', '-synced_at').first() + or profile.sede_subscriptions.order_by('-last_payment_date', '-synced_at').first() + ) + if not primary_subscription or not primary_subscription.is_active: raise Http404 from user_profile.services.sede_mercadopago import format_payment_method @@ -1125,10 +1129,11 @@ def la_sede_view(request): context = _mi_fuego_sidebar_context(request) context.update({ 'profile': profile, - 'payment_method_label': format_payment_method(profile.sede_payment_method), + 'primary_subscription': primary_subscription, + 'payment_method_label': format_payment_method(primary_subscription.payment_method), 'subscription_status_label': SUBSCRIPTION_STATUS_LABELS.get( - profile.sede_subscription_status, - profile.sede_subscription_status or 'Activa', + primary_subscription.status, + primary_subscription.status or 'Activa', ), 'nav_primary': 'la_sede', 'nav_secondary': 'credencial',