diff --git a/ops/pgbackup/export-payment-events.sh b/ops/pgbackup/export-payment-events.sh new file mode 100755 index 0000000..a6162fa --- /dev/null +++ b/ops/pgbackup/export-payment-events.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# روی سرورِ بکاپ (۲۴۷). دفترِ رویدادهای خرید (payment_paymentevent) و سفارش/تراکنش را از روی همین +# بکاپ (آخرین نسخه‌ی کامل + همه‌ی WALِ رسیده) به CSVِ خوانا در `payment-log/` کنارِ `base/` و `wal/` می‌نویسد. +# هیچ اتصالی به ۲۴۹ یا Postgresِ دیگرانِ این ماشین ندارد: همان روشِ restore-check.sh، نمونه‌ی موقت روی +# سوکتِ داخلِ پوشه (بی‌پورتِ شبکه) که آخرش پاک می‌شود. فقط زیرِ ~/zareian-pgbackup می‌نویسد. +# خروجی: payment-log/payment-events-.csv، orders-…csv، transactions-…csv؛ ۱۴ فایلِ آخرِ هر نوع می‌ماند. +# توجه: نسخه‌ی کامل تا چند دقیقه‌ی WAL عقب‌تر از ۲۴۹ می‌شود، ولی WALِ رسیده همه اعمال می‌شود. +set -euo pipefail +ROOT=/home/administrator/zareian-pgbackup +BIN=/usr/lib/postgresql/14/bin +OUT="$ROOT/payment-log" +mkdir -p "$OUT" + +# دیسکِ این ماشین مشترک است؛ زیرِ ۳ گیگ فضای آزاد، شروع نمی‌کنیم. +free_kb=$(df --output=avail -k "$ROOT" | tail -1) +[ "$free_kb" -gt 3000000 ] || { echo "disk too full ($free_kb KB free); skipping" >&2; exit 1; } + +latest=$(ls -1d "$ROOT"/base/2* | tail -1) +work=$(mktemp -d "$ROOT/export-events.XXXXXX") +trap '"$BIN/pg_ctl" -D "$work/data" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$work"' EXIT + +cp -a "$latest" "$work/data" +mkdir "$work/wal" +cp "$ROOT"/wal/* "$work/wal/" +for f in "$work"/wal/*.partial; do [ -e "$f" ] && mv "$f" "${f%.partial}"; done + +cat > "$work/data/postgresql.conf" < "$work/data/pg_hba.conf" +: > "$work/data/postgresql.auto.conf" +touch "$work/data/recovery.signal" + +"$BIN/pg_ctl" -D "$work/data" -l "$work/log" -w -t 600 start >/dev/null +for _ in $(seq 120); do + [ "$("$BIN/psql" -h "$work" -U postgres -d zareian_mathapp -Atc 'select pg_is_in_recovery()')" = f ] && break + sleep 1 +done + +stamp=$(date -u +%Y%m%dT%H%M%SZ) +q() { "$BIN/psql" -h "$work" -U postgres -d zareian_mathapp -c "\\copy ($1) to '$2' csv header"; } +q "select id, created_at, order_id, user_id, provider, event, detail from payment_paymentevent order by id" "$OUT/payment-events-$stamp.csv" +q "select id, created_at, user_id, plan_id, plan_title, amount, status, provider, gateway_authority, subscription_id from payment_order order by id" "$OUT/orders-$stamp.csv" +q "select id, created_at, order_id, gateway, gateway_ref_id, status, paid_at from payment_transaction order by id" "$OUT/transactions-$stamp.csv" +chmod 600 "$OUT"/*-"$stamp".csv + +for kind in payment-events orders transactions; do + ls -1t "$OUT/$kind"-*.csv | tail -n +15 | xargs -r rm -f -- +done +echo "wrote $OUT/*-$stamp.csv from base $(basename "$latest")" diff --git a/ops/pgbackup/zareian-pg-export-events.service b/ops/pgbackup/zareian-pg-export-events.service new file mode 100644 index 0000000..3a1aa06 --- /dev/null +++ b/ops/pgbackup/zareian-pg-export-events.service @@ -0,0 +1,7 @@ +[Unit] +Description=Zareian MathApp: export payment events from the backup copy to CSV + +[Service] +Type=oneshot +User=administrator +ExecStart=/home/administrator/zareian-pgbackup/bin/export-payment-events.sh diff --git a/ops/pgbackup/zareian-pg-export-events.timer b/ops/pgbackup/zareian-pg-export-events.timer new file mode 100644 index 0000000..b3a6083 --- /dev/null +++ b/ops/pgbackup/zareian-pg-export-events.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Zareian MathApp: payment events CSV every 6 hours + +[Timer] +OnCalendar=*-*-* 01,07,13,19:15:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/payment/admin.py b/payment/admin.py index 0f76fc8..4fc7f8b 100644 --- a/payment/admin.py +++ b/payment/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin, messages from django.utils.html import format_html from . import bazaar -from .models import BazaarCredential, Order, Transaction +from .models import BazaarCredential, Order, PaymentEvent, Transaction class TransactionInline(admin.TabularInline): diff --git a/payment/events.py b/payment/events.py new file mode 100644 index 0000000..9bb7dd3 --- /dev/null +++ b/payment/events.py @@ -0,0 +1,29 @@ +import logging + +from django.db import transaction + +from .models import PaymentEvent + +logger = logging.getLogger(__name__) + + +def record(event: str, *, order=None, user_id=None, provider: str = '', **detail) -> None: + """ + یک ردیف در PaymentEvent می‌نویسد. + + شکستِ نوشتن *را می‌بلعد ولی لاگ می‌کند* (logger.exception): این دفتر فقط ردگیری است و خطایش + نباید پرداختِ کاربر را بشکند یا سفارشِ پرداخت‌شده را ناتمام بگذارد. savepoint دارد تا خطای + این insert تراکنشِ بیرونی را در Postgres خراب نکند. زیرِ فشار/قطعیِ دیتابیس آزموده نشده. + """ + try: + with transaction.atomic(): + PaymentEvent.objects.create( + order_id=order.id if order is not None else None, + user_id=user_id if user_id is not None else (order.user_id if order is not None else None), + provider=provider or (order.provider if order is not None else ''), + event=event, + detail=detail, + ) + except Exception: + logger.exception('payment event %s could not be recorded (order=%s)', event, + getattr(order, 'id', None)) diff --git a/payment/migrations/0014_paymentevent.py b/payment/migrations/0014_paymentevent.py new file mode 100644 index 0000000..00d8f89 --- /dev/null +++ b/payment/migrations/0014_paymentevent.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.17 on 2026-10-13 10:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0013_remove_question_packs'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('user_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('provider', models.CharField(blank=True, max_length=20)), + ('event', models.CharField(db_index=True, max_length=40)), + ('detail', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/payment/models.py b/payment/models.py index 830017b..7e80f6e 100644 --- a/payment/models.py +++ b/payment/models.py @@ -115,3 +115,28 @@ class BazaarCredential(models.Model): def __str__(self): return 'متصل' if self.refresh_token else 'متصل نیست' + + +class PaymentEvent(models.Model): + """ + دفترِ رویدادهای مسیرِ خرید (ساخت سفارش، رفتن به درگاه، بازگشت از درگاه، نتیجه‌ی verify، + لغو/بازپرداخت). فقط افزودنی است و هیچ منطقی بر آن تکیه ندارد. + + چرا جدول و نه فایلِ لاگ: دیتابیس لحظه‌ای به سرورِ بکاپِ ۲۴۷ می‌رود (WAL)، پس این ردیف‌ها + بی‌هیچ کانال و دسترسیِ تازه همان‌جا هم می‌رسند؛ فایلِ خوانا از روی همان نسخه ساخته می‌شود + (`ops/pgbackup/export-payment-events.sh`). `StatusChangeLog` فقط تغییرِ وضعیت را نگه می‌دارد؛ + «کاربر رفت به درگاه ولی برنگشت» در آن نیست و این‌جا هست. + در `detail` هیچ توکنِ خرید یا کلیدی نمی‌رود؛ authority فقط ۶۴ نویسه‌ی اول. + """ + order_id = models.IntegerField(null=True, blank=True, db_index=True) # ارجاع سبک به Order؛ بی‌سفارش هم می‌شود + user_id = models.IntegerField(null=True, blank=True, db_index=True) + provider = models.CharField(max_length=20, blank=True) + event = models.CharField(max_length=40, db_index=True) + detail = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f'order {self.order_id}: {self.event}' diff --git a/payment/services.py b/payment/services.py index 86ff01e..49e73e2 100644 --- a/payment/services.py +++ b/payment/services.py @@ -17,6 +17,8 @@ from rest_framework.exceptions import APIException from statuslog.signals import status_changed +from . import events + from .models import Order, Transaction from .providers import PROVIDERS, provider_for @@ -135,6 +137,8 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT', subscription_id=subscription_id, plan_id=plan_id, provider=provider, ) + events.record('order_created', order=order, plan_id=plan_id, amount=amount, currency=currency, + duration_days=order.duration_days, sku=bazaar_product_sku) # بازار paymentUrl ندارد (اپ با Poolakey می‌خرد)؛ زرین‌پال آدرسِ PaymentRedirectView را دارد (providers.py). payment_url = provider_for(order.provider).payment_url(order) return order, payment_url @@ -387,6 +391,7 @@ def cancel_pending_order(order: Order, *, staff: str = '') -> None: raise OrderStateError(f'سفارشِ {order.id} «{order.get_status_display()}» است؛ فقط سفارشِ در انتظار لغو می‌شود.') order.status = 'canceled' order.save(update_fields=['status']) + events.record('admin_cancel', order=order, staff=staff) status_changed.send(sender='payment.Order', object_id=order.id, old_status='pending', new_status='canceled', event=f'admin_cancel:{staff}'[:50], user_id=order.user_id) @@ -428,6 +433,7 @@ def refund_order(order: Order, *, staff: str = '') -> dict: order.status = 'refunded' order.save(update_fields=['status']) + events.record('admin_refund', order=order, staff=staff, subscription_days=summary['subscription_days']) Transaction.objects.create(order=order, gateway='admin', status='refunded', gateway_ref_id=f'refund-{order.id}') status_changed.send(sender='payment.Order', object_id=order.id, old_status='paid', diff --git a/payment/tests_events.py b/payment/tests_events.py new file mode 100644 index 0000000..ecd59d9 --- /dev/null +++ b/payment/tests_events.py @@ -0,0 +1,27 @@ +from unittest import mock + +from django.test import TestCase + +from payment import events +from payment.models import Order, PaymentEvent +from payment.services import cancel_pending_order, create_order + + +class PaymentEventTests(TestCase): + def test_create_order_records_event_once_per_order(self): + order, _ = create_order(user_id=7, amount=1000, idempotency_key='k-1') + create_order(user_id=7, amount=1000, idempotency_key='k-1') # همان سفارش، رویدادِ دوم نه + rows = PaymentEvent.objects.filter(order_id=order.id, event='order_created') + self.assertEqual(rows.count(), 1) + self.assertEqual(rows.get().user_id, 7) + + def test_cancel_records_event(self): + order, _ = create_order(user_id=7, amount=1000) + cancel_pending_order(order, staff='tester') + self.assertTrue(PaymentEvent.objects.filter(order_id=order.id, event='admin_cancel').exists()) + + def test_failure_to_record_does_not_raise(self): + with mock.patch.object(PaymentEvent.objects, 'create', side_effect=RuntimeError('db')): + with self.assertLogs('payment.events', level='ERROR'): + events.record('x', user_id=1) + self.assertEqual(Order.objects.count(), 0) diff --git a/payment/views.py b/payment/views.py index 48d0152..ae904ba 100644 --- a/payment/views.py +++ b/payment/views.py @@ -15,7 +15,7 @@ from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView -from . import bazaar, zarinpal +from . import bazaar, events, zarinpal from .models import Order from .permissions import HasInternalServiceKey from .serializers import ( @@ -103,12 +103,18 @@ class PaymentRedirectView(APIView): if order.status != 'pending' or order.gateway_authority: break requested_amount = order.amount - result = zarinpal.request_payment(order) + try: + result = zarinpal.request_payment(order) + except Exception as exc: + events.record('gateway_request_failed', order=order, error=type(exc).__name__) + raise Order.objects.filter(pk=order.pk, gateway_authority='', amount=requested_amount).update( gateway_authority=result.authority, ) order.refresh_from_db(fields=['gateway_authority', 'amount', 'status']) + events.record('gateway_redirect', order=order, status=order.status, + authority_set=bool(order.gateway_authority)) if order.status != 'pending': # سفارش از قبل نتیجه گرفته (paid/failed، یا کدِ ۱۰۰٪ همین الان)؛ چیزی برای پرداختِ تازه نیست. # کاربر به اپ برمی‌گردد با همان نتیجه، نه به ریشه‌ی دامنه‌ی API. @@ -159,7 +165,10 @@ class ZarinpalCallbackView(APIView): if order is None: # کال‌بکِ بی‌سفارش: یا Authorityِ جعلی یا سفارشی که از دیتابیس رفته؛ در دیتابیس ردی ندارد. logger.warning('zarinpal callback: no order for authority=%s status=%s', authority[:64], status_param[:10]) + events.record('callback_unknown_authority', provider='gateway', + authority=authority[:64], status_param=status_param[:10]) raise Http404 + events.record('callback_received', order=order, authority=authority[:64], status_param=status_param[:10]) if status_param != 'OK': new_status, gateway_ref_id = 'failed', '' else: @@ -168,6 +177,7 @@ class ZarinpalCallbackView(APIView): ) new_status = 'paid' if result.is_valid else 'failed' gateway_ref_id = result.ref_id + events.record('verify_result', order=order, valid=bool(result.is_valid), ref_id=gateway_ref_id) with transaction.atomic(): locked_order = get_object_or_404(Order.objects.select_for_update(), pk=order.pk) @@ -306,6 +316,7 @@ class BazaarVerifyPurchaseView(APIView): # apply_order_payment_result سفارش را از نو می‌خواند و همان را عوض می‌کند؛ این نمونه هنوز # وضعیتِ پیش از پرداخت را دارد. بدونِ این، پاسخ «pending» بود درحالی‌که سفارش «paid» شده بود. order.refresh_from_db(fields=['status']) + events.record('bazaar_verify', order=order, valid=bool(result.is_valid), status=order.status) data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid} return Response(BazaarVerifyResponseSerializer(data).data) @@ -366,6 +377,7 @@ class BazaarStartPurchaseView(APIView): # علامتِ «خرید شروع شد» تا کدِ تخفیفِ بعدی نیاید؛ قیمت همان قیمتِ کامل است. order.bazaar_price_rial = _to_rial(order.amount, order.currency) order.save(update_fields=['bazaar_price_rial']) + events.record('bazaar_start', order=order, amount=order.amount, discounted=token is not None) data = { 'orderId': str(order.id), 'sku': order.bazaar_product_sku,