Initial commit

This commit is contained in:
2025-11-18 03:36:49 +08:00
commit d17c7efb3c
7078 changed files with 831480 additions and 0 deletions

0
billing/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

21
billing/admin.py Normal file
View File

@@ -0,0 +1,21 @@
from django.contrib import admin
from .models import Invoice, InvoiceItem
class InvoiceItemInline(admin.TabularInline):
model = InvoiceItem
extra = 0
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
list_display = ('user', 'period_start', 'period_end', 'amount_total', 'status', 'created_at')
list_filter = ('status',)
inlines = [InvoiceItemInline]
@admin.register(InvoiceItem)
class InvoiceItemAdmin(admin.ModelAdmin):
list_display = ('invoice', 'domain', 'description', 'quantity', 'unit_price', 'amount')
# Register your models here.

6
billing/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'billing'

View File

@@ -0,0 +1 @@
"""Django management package for billing app."""

View File

@@ -0,0 +1,83 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from django.db import transaction
from billing.models import Invoice
from core.models import SystemSettings, OperationLog
from core.goedge_client import GoEdgeClient
class Command(BaseCommand):
help = '对逾期未支付账单执行未支付策略(停服或限速)。支持 dry-run 与用户/时间过滤。'
def add_arguments(self, parser):
parser.add_argument('--days-overdue', type=int, default=0, help='逾期天数阈值默认0表示只要未支付即处理')
parser.add_argument('--user-id', type=int, default=0, help='仅处理指定用户(可选)')
parser.add_argument('--dry-run', action='store_true', default=False, help='试运行,不落库与不调用接口')
@transaction.atomic
def handle(self, *args, **options):
days_overdue = int(options.get('days_overdue') or 0)
user_id = int(options.get('user_id') or 0)
dry = bool(options.get('dry_run'))
today = timezone.now().date()
qs = Invoice.objects.filter(status=Invoice.STATUS_UNPAID)
if user_id:
qs = qs.filter(user_id=user_id)
if days_overdue > 0:
# 以账单周期结束日为逾期基准
cutoff = today - timezone.timedelta(days=days_overdue)
qs = qs.filter(period_end__lte=cutoff)
invoices = list(qs)
if not invoices:
self.stdout.write(self.style.NOTICE('无符合条件的未支付账单'))
return
sys = SystemSettings.objects.order_by('id').first()
policy = getattr(sys, 'default_overage_policy', {}) or {}
action_type = (policy.get('action') or '').lower()
limit_bps = int(policy.get('limit_bps') or 0)
client = None
if not dry:
client = GoEdgeClient()
total_domains = 0
for inv in invoices:
applied = []
items = inv.items.select_related('domain').all()
for it in items:
d = it.domain
if not d or not d.edge_server_id:
continue
total_domains += 1
if dry:
applied.append({'domain': d.name, 'action': action_type or 'shutdown', 'limit_bps': limit_bps})
continue
web_id = client.find_server_web_id(int(d.edge_server_id))
if not web_id:
continue
if action_type == 'limit' and limit_bps > 0:
client.update_http_web_request_limit(web_id, {'isOn': True, 'rateBytes': int(limit_bps)})
applied.append({'domain': d.name, 'action': 'limit', 'rateBytes': limit_bps})
else:
client.update_http_web_shutdown(web_id, {'isOn': True})
if d.status != 'suspended':
d.status = 'suspended'
d.save(update_fields=['status', 'updated_at'])
applied.append({'domain': d.name, 'action': 'shutdown'})
if applied:
try:
OperationLog.objects.create(
actor=None,
action='apply_overage_policy_cron',
target=f"Invoice#{inv.id}",
detail=str(applied)
)
except Exception:
pass
self.stdout.write(self.style.SUCCESS(f"Invoice#{inv.id} 应用策略:{len(applied)} 个域名"))
self.stdout.write(self.style.SUCCESS(f'完成:处理账单 {len(invoices)} 个,涉及域名 {total_domains}'))

View File

@@ -0,0 +1,200 @@
from datetime import date, timedelta
from decimal import Decimal, ROUND_HALF_UP
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from django.contrib.auth import get_user_model
from billing.models import Invoice, InvoiceItem
from domains.models import Domain, DomainTrafficDaily
from core.models import SystemSettings
User = get_user_model()
def month_range_from_str(month_str: str):
try:
year, month = map(int, month_str.split('-'))
start = date(year, month, 1)
except Exception as e:
raise CommandError(f"无效的 --month 参数,格式应为 YYYY-MM{e}")
# 下月第一天
if month == 12:
next_month_start = date(year + 1, 1, 1)
else:
next_month_start = date(year, month + 1, 1)
end = next_month_start - timedelta(days=1)
return start, end
def previous_month_range():
today = timezone.now().date()
this_month_start = today.replace(day=1)
last_day_prev = this_month_start - timedelta(days=1)
start_prev = last_day_prev.replace(day=1)
return start_prev, last_day_prev
class Command(BaseCommand):
help = "按自然月生成用户账单(套餐费 + 超量流量)。默认生成上一自然月,可用 --month 指定。"
def add_arguments(self, parser):
parser.add_argument('--month', type=str, help='计费月份,格式 YYYY-MM如 2025-10')
parser.add_argument('--user-id', type=int, help='仅为指定用户生成账单(可选)')
parser.add_argument('--overwrite', action='store_true', help='若已存在账单则覆盖重建(删除后重建)')
parser.add_argument('--dry-run', action='store_true', help='试运行,仅输出计算结果,不写入数据库')
@transaction.atomic
def handle(self, *args, **options):
# 计费周期
if options.get('month'):
period_start, period_end = month_range_from_str(options['month'])
else:
period_start, period_end = previous_month_range()
user_id = options.get('user_id')
overwrite = options.get('overwrite', False)
dry_run = options.get('dry_run', False)
self.stdout.write(self.style.NOTICE(
f"生成账单周期:{period_start}{period_end}"
))
users_qs = User.objects.all()
if user_id:
users_qs = users_qs.filter(id=user_id)
if not users_qs.exists():
raise CommandError(f"用户 {user_id} 不存在")
sys = SystemSettings.objects.order_by('id').first()
total_invoices = 0
total_amount = Decimal('0.00')
for user in users_qs:
# 处理已存在账单
existing = Invoice.objects.filter(user=user, period_start=period_start, period_end=period_end).first()
if existing and not overwrite:
self.stdout.write(self.style.WARNING(
f"跳过用户 {user.id}{user.username}该周期账单已存在Invoice#{existing.id}"
))
continue
if existing and overwrite and not dry_run:
existing.delete()
self.stdout.write(self.style.WARNING(
f"已删除旧账单:用户 {user.id}{user.username} Invoice#{existing.id}"
))
# 汇总用户域名的费用
domains = Domain.objects.filter(user=user, status=Domain.STATUS_ACTIVE)
amount_plan_total = Decimal('0.00')
amount_overage_total = Decimal('0.00')
items = []
for d in domains:
plan = d.current_plan
base_price = Decimal(str(plan.base_price_per_domain)) if plan and plan.base_price_per_domain is not None else Decimal('0.00')
included_gb = Decimal(str(plan.included_traffic_gb_per_domain)) if plan and plan.included_traffic_gb_per_domain is not None else None
overage_price = Decimal(str(plan.overage_price_per_gb)) if plan and plan.overage_price_per_gb is not None else Decimal('0.00')
# 若域名自定义超量单价优先
if d.custom_overage_price_per_gb is not None:
overage_price = Decimal(str(d.custom_overage_price_per_gb))
# 用户级默认免费额度覆盖
user_default_override = None
if hasattr(user, 'profile') and user.profile.default_free_traffic_gb_per_domain_override is not None:
user_default_override = Decimal(str(user.profile.default_free_traffic_gb_per_domain_override))
# 系统全局默认
sys_default_gb = Decimal(str(sys.default_free_traffic_gb_per_domain)) if sys and sys.default_free_traffic_gb_per_domain is not None else Decimal('0')
# 最终基础配额:优先 Plan其次用户覆盖再次系统默认
base_quota_gb = Decimal('0')
if included_gb is not None:
base_quota_gb = Decimal(str(included_gb))
elif user_default_override is not None:
base_quota_gb = user_default_override
else:
base_quota_gb = sys_default_gb
# 本周期域名额外赠送
extra_gb = Decimal(str(d.extra_free_traffic_gb_current_cycle or 0))
# 统计周期用量
traffic_qs = DomainTrafficDaily.objects.filter(domain=d, day__gte=period_start, day__lte=period_end)
total_bytes = sum(t.bytes for t in traffic_qs)
used_gb = Decimal(str(total_bytes)) / Decimal('1073741824') # 1024^3
used_gb = used_gb.quantize(Decimal('0.001'), rounding=ROUND_HALF_UP)
# 超量(不为负)
over_gb = used_gb - (base_quota_gb + extra_gb)
if over_gb < Decimal('0'):
over_gb = Decimal('0')
over_gb = over_gb.quantize(Decimal('0.001'), rounding=ROUND_HALF_UP)
# 费用累计
if base_price > 0:
amount_plan_total += base_price
items.append({
'domain': d,
'description': '基础套餐费用',
'quantity': Decimal('1'),
'unit_price': base_price,
'amount': base_price,
})
if over_gb > 0 and overage_price > 0:
over_amount = (over_gb * overage_price).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
amount_overage_total += over_amount
items.append({
'domain': d,
'description': '超量流量费用',
'quantity': over_gb,
'unit_price': overage_price,
'amount': over_amount,
})
amount_adjustment = Decimal('0.00')
amount_total = (amount_plan_total + amount_overage_total + amount_adjustment).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
if dry_run:
self.stdout.write(self.style.SUCCESS(
f"[DRY] 用户 {user.id}{user.username} 套餐费:¥{amount_plan_total} 超量费:¥{amount_overage_total} 总计:¥{amount_total}"
))
continue
# 创建账单与明细
invoice = Invoice.objects.create(
user=user,
period_start=period_start,
period_end=period_end,
amount_plan_total=amount_plan_total,
amount_overage_total=amount_overage_total,
amount_adjustment=amount_adjustment,
amount_total=amount_total,
status='unpaid',
)
for it in items:
InvoiceItem.objects.create(
invoice=invoice,
domain=it['domain'],
description=it['description'],
quantity=it['quantity'],
unit_price=it['unit_price'],
amount=it['amount'],
)
total_invoices += 1
total_amount += amount_total
self.stdout.write(self.style.SUCCESS(
f"已生成账单Invoice#{invoice.id} 用户 {user.id}{user.username} 总额:¥{amount_total}"
))
self.stdout.write(self.style.SUCCESS(
f"生成完成:共 {total_invoices} 个账单,合计金额 ¥{total_amount}"
))

View File

@@ -0,0 +1,55 @@
# Generated by Django 5.2.8 on 2025-11-07 07:39
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('domains', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Invoice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('period_start', models.DateField()),
('period_end', models.DateField()),
('amount_plan_total', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('amount_overage_total', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('amount_adjustment', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('amount_total', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('status', models.CharField(choices=[('unpaid', '未支付'), ('paid', '已支付'), ('cancelled', '已取消')], default='unpaid', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('paid_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': '账单',
'verbose_name_plural': '账单',
'ordering': ['-period_end'],
},
),
migrations.CreateModel(
name='InvoiceItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.CharField(max_length=200)),
('quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12)),
('unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('domain', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='domains.domain')),
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='billing.invoice')),
],
options={
'verbose_name': '账单项',
'verbose_name_plural': '账单项',
},
),
]

View File

47
billing/models.py Normal file
View File

@@ -0,0 +1,47 @@
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class Invoice(models.Model):
STATUS_UNPAID = 'unpaid'
STATUS_PAID = 'paid'
STATUS_CANCELLED = 'cancelled'
STATUS_CHOICES = [
(STATUS_UNPAID, '未支付'),
(STATUS_PAID, '已支付'),
(STATUS_CANCELLED, '已取消'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='invoices')
period_start = models.DateField()
period_end = models.DateField()
amount_plan_total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
amount_overage_total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
amount_adjustment = models.DecimalField(max_digits=12, decimal_places=2, default=0)
amount_total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_UNPAID)
created_at = models.DateTimeField(auto_now_add=True)
paid_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = '账单'
verbose_name_plural = '账单'
ordering = ['-period_end']
class InvoiceItem(models.Model):
invoice = models.ForeignKey(Invoice, related_name='items', on_delete=models.CASCADE)
domain = models.ForeignKey('domains.Domain', on_delete=models.SET_NULL, null=True, blank=True)
description = models.CharField(max_length=200)
quantity = models.DecimalField(max_digits=12, decimal_places=3, default=0) # GB
unit_price = models.DecimalField(max_digits=12, decimal_places=2, default=0)
amount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
class Meta:
verbose_name = '账单项'
verbose_name_plural = '账单项'
# Create your models here.

72
billing/tests.py Normal file
View File

@@ -0,0 +1,72 @@
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.urls import reverse
from decimal import Decimal
from django.utils import timezone
from plans.models import Plan
from domains.models import Domain, DomainTrafficDaily
from billing.models import Invoice
User = get_user_model()
class BillingFlowTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='u2', email='u2@example.com', password='p', is_staff=True)
self.plan = Plan.objects.create(name='P', base_price_per_domain=Decimal('10.00'), included_traffic_gb_per_domain=1, overage_price_per_gb=Decimal('1.00'))
today = timezone.now().date()
month_start = today.replace(day=1)
next_month = (month_start.replace(year=month_start.year + 1, month=1) if month_start.month == 12 else month_start.replace(month=month_start.month + 1))
cycle_end = next_month - timezone.timedelta(days=1)
self.domain = Domain.objects.create(user=self.user, name='ex2.com', status=Domain.STATUS_ACTIVE, current_plan=self.plan, current_cycle_start=month_start, current_cycle_end=cycle_end)
DomainTrafficDaily.objects.create(domain=self.domain, day=month_start, bytes=2 * (1024 ** 3))
def test_generate_invoice_and_apply_policy_dry(self):
call_command('generate_invoices')
inv = Invoice.objects.filter(user=self.user).first()
self.assertIsNotNone(inv)
self.assertEqual(inv.status, Invoice.STATUS_UNPAID)
call_command('apply_invoice_policies', dry_run=True)
inv.refresh_from_db()
self.assertEqual(inv.status, Invoice.STATUS_UNPAID)
def test_mark_paid_via_admin_view(self):
call_command('generate_invoices')
inv = Invoice.objects.filter(user=self.user).first()
c = Client()
c.login(username='u2', password='p')
resp = c.post(reverse('admin_panel:billing_detail', kwargs={'invoice_id': inv.id}), {'action': 'mark_paid'})
self.assertEqual(resp.status_code, 302)
inv.refresh_from_db()
self.assertEqual(inv.status, Invoice.STATUS_PAID)
def test_epay_notify_marks_paid(self):
call_command('generate_invoices')
inv = Invoice.objects.filter(user=self.user).first()
from core.models import SystemSettings
s = SystemSettings.objects.order_by('id').first()
if not s:
s = SystemSettings.objects.create()
s.epay_api_base_url = 'https://api.example.com'
s.epay_pid = 'pid1'
s.epay_key = 'k123'
s.save()
params = {
'pid': s.epay_pid,
'out_trade_no': f'INV{inv.id}',
'money': str(inv.amount_total),
'type': 'alipay',
'trade_status': 'SUCCESS',
'sign_type': 'MD5',
}
import hashlib
src = '&'.join(f"{k}={params[k]}" for k in sorted(params) if k not in ['sign', 'sign_type']) + '&key=' + s.epay_key
params['sign'] = hashlib.md5(src.encode('utf-8')).hexdigest().upper()
c = Client()
resp = c.get(reverse('billing:notify'), params)
self.assertEqual(resp.status_code, 200)
inv.refresh_from_db()
self.assertEqual(inv.status, Invoice.STATUS_PAID)

12
billing/urls.py Normal file
View File

@@ -0,0 +1,12 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.list_billing, name='list'),
path('inv/<int:invoice_id>/', views.invoice_detail, name='detail'),
path('inv/<int:invoice_id>/csv/', views.invoice_detail_csv, name='detail_csv'),
path('inv/<int:invoice_id>/pay/', views.invoice_pay, name='pay'),
path('payment/notify/', views.payment_notify, name='notify'),
path('payment/return/', views.payment_return, name='return'),
]

184
billing/views.py Normal file
View File

@@ -0,0 +1,184 @@
from django.shortcuts import render
import logging
from django.contrib.auth.decorators import login_required
from django.db.models import Sum
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django.contrib import messages
from .models import Invoice
from domains.models import DomainTrafficDaily
from decimal import Decimal
from core.models import OperationLog
from core.models import SystemSettings
from django.http import HttpResponse
import hashlib
import urllib.parse
@login_required
def list_billing(request):
status = request.GET.get('status')
invoices_qs = Invoice.objects.filter(user=request.user)
if status:
invoices_qs = invoices_qs.filter(status=status)
invoices = invoices_qs.order_by('-period_end')
totals = invoices_qs.aggregate(total_amount=Sum('amount_total'))
return render(request, 'billing/list.html', {
'invoices': invoices,
'total_amount': totals.get('total_amount') or 0,
'status': status or '',
'status_choices': getattr(Invoice, 'STATUS_CHOICES', []),
})
@login_required
def invoice_detail(request, invoice_id: int):
invoice = get_object_or_404(Invoice, id=invoice_id)
# 权限仅本人或staff可查看
if not (invoice.user_id == request.user.id or request.user.is_staff):
return redirect('billing:list')
if request.method == 'POST':
action = request.POST.get('action')
if action == 'mark_paid' and invoice.status == Invoice.STATUS_UNPAID:
invoice.status = Invoice.STATUS_PAID
invoice.paid_at = timezone.now()
invoice.save(update_fields=['status', 'paid_at'])
try:
OperationLog.objects.create(
actor=request.user,
action='invoice_mark_paid',
target=f"Invoice#{invoice.id}",
detail=f"period={invoice.period_start}{invoice.period_end}; amount_total={invoice.amount_total}"
)
except Exception:
logger.exception('operation log write failed')
messages.success(request, '账单已标记为已支付。')
return redirect('billing:detail', invoice_id=invoice.id)
items = invoice.items.select_related('domain').all()
plan_total = invoice.amount_plan_total
overage_total = invoice.amount_overage_total
adjustment = invoice.amount_adjustment
amount_total = invoice.amount_total
# 聚合账单周期内的域名用量GB
traffic_by_domain = {}
domain_stats = []
period_start = invoice.period_start
period_end = invoice.period_end
for it in items:
if it.domain_id and it.domain_id not in traffic_by_domain:
bytes_sum = DomainTrafficDaily.objects.filter(domain_id=it.domain_id, day__gte=period_start, day__lte=period_end).aggregate(b=Sum('bytes'))['b'] or 0
gb = round(Decimal(str(bytes_sum)) / Decimal(str(1024 ** 3)), 3)
traffic_by_domain[it.domain_id] = gb
domain_stats.append({'domain': it.domain, 'gb': gb})
return render(request, 'billing/detail.html', {
'invoice': invoice,
'items': items,
'plan_total': plan_total,
'overage_total': overage_total,
'adjustment': adjustment,
'amount_total': amount_total,
'traffic_by_domain': traffic_by_domain,
'domain_stats': domain_stats,
'period_start': period_start,
'period_end': period_end,
})
@login_required
def invoice_pay(request, invoice_id: int):
invoice = get_object_or_404(Invoice, id=invoice_id, user=request.user)
if invoice.status != Invoice.STATUS_UNPAID:
return redirect('billing:detail', invoice_id=invoice.id)
settings_obj = SystemSettings.objects.order_by('id').first()
api = (settings_obj.epay_api_base_url or '').rstrip('/')
pid = settings_obj.epay_pid or ''
key = settings_obj.epay_key or ''
if not (api and pid and key):
messages.error(request, '支付未配置')
return redirect('billing:detail', invoice_id=invoice.id)
pay_type = request.GET.get('type') or 'alipay'
params = {
'pid': pid,
'type': pay_type,
'out_trade_no': f'INV{invoice.id}',
'notify_url': request.build_absolute_uri(reverse('billing:notify')),
'return_url': request.build_absolute_uri(reverse('billing:return')),
'name': f'Invoice#{invoice.id}',
'money': str(invoice.amount_total),
'sitename': 'PyGoEdge',
'sign_type': 'MD5',
}
sign_src = '&'.join(f"{k}={params[k]}" for k in sorted(params) if k not in ['sign', 'sign_type'] and params[k] != '') + '&key=' + key
sign = hashlib.md5(sign_src.encode('utf-8')).hexdigest().upper()
params['sign'] = sign
url = api + '/submit.php?' + urllib.parse.urlencode(params)
try:
OperationLog.objects.create(actor=request.user, action='invoice_pay_create', target=f'Invoice#{invoice.id}', detail=url)
except Exception:
pass
return redirect(url)
def _verify_epay_sign(params: dict, key: str) -> bool:
p = {k: v for k, v in params.items() if k not in ['sign', 'sign_type'] and v is not None and v != ''}
src = '&'.join(f"{k}={p[k]}" for k in sorted(p)) + '&key=' + key
calc = hashlib.md5(src.encode('utf-8')).hexdigest().upper()
return calc == (params.get('sign') or '').upper()
def payment_notify(request):
settings_obj = SystemSettings.objects.order_by('id').first()
key = settings_obj.epay_key or ''
params = dict(request.GET.items())
ok = (_verify_epay_sign(params, key) if key else False)
out_trade_no = params.get('out_trade_no') or ''
trade_status = params.get('trade_status') or ''
money = params.get('money') or ''
if ok and out_trade_no.startswith('INV') and trade_status:
inv_id = int(out_trade_no.replace('INV', ''))
inv = Invoice.objects.filter(id=inv_id).first()
if inv and inv.status == Invoice.STATUS_UNPAID:
if str(inv.amount_total) == str(money):
inv.status = Invoice.STATUS_PAID
inv.paid_at = timezone.now()
inv.save(update_fields=['status', 'paid_at'])
try:
OperationLog.objects.create(actor=None, action='invoice_paid_notify', target=f'Invoice#{inv.id}', detail=out_trade_no)
except Exception:
pass
return HttpResponse('SUCCESS')
return HttpResponse('FAIL')
@login_required
def payment_return(request):
messages.info(request, '支付流程已完成,如账单仍显示未支付,请稍后或刷新页面。')
return redirect('billing:list')
@login_required
def invoice_detail_csv(request, invoice_id: int):
invoice = get_object_or_404(Invoice, id=invoice_id)
if not (invoice.user_id == request.user.id or request.user.is_staff):
return redirect('billing:list')
import csv
from django.http import HttpResponse
response = HttpResponse(content_type='text/csv; charset=utf-8')
response['Content-Disposition'] = f'attachment; filename="invoice_{invoice.id}.csv"'
writer = csv.writer(response)
writer.writerow(['Domain', 'Description', 'Quantity(GB)', 'Unit Price', 'Amount'])
for it in invoice.items.select_related('domain').all():
writer.writerow([
it.domain.name if it.domain else '',
it.description,
str(it.quantity),
str(it.unit_price),
str(it.amount),
])
return response
# Create your views here.
logger = logging.getLogger(__name__)