Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement schedule task to collect billing invoice #417

Merged

Conversation

lcduong
Copy link
Contributor

@lcduong lcduong commented Oct 29, 2024

This PR is part of issue #383
Implement:

  1. Use celery beat for schedule task
  2. Implement task billing collect to collect all billing for all events on monthly basic.
    Tasks implemented
  • Billing invoice collect: Collect billing on a monthly basis for all events which have order in last month. Running at: 0:00 AM 1st of every month
  • Billing invoice notification: Get all billing invoices which created in last month. Trigger an email to Organizer' Primary Contact Email (in billing settings). attach invoice pdf. Running at: 0:10 AM 1st of every month.
  • Billing charge on invoice: Get all pending invoice created last month and its status is PENDING, trigger stripe to charge for that event. Running at 00:20 1st of every month.
  • Billing invoice payment retry: Get all pending invoice created last month, check if today is schedule date for payment retry, if yes, then trigger Stripe to charge pending invoice again. Run every day at 00:30 AM
  • Billing invoice warning: Get all billing invoices which status is PENDING and 'reminder_enabled' flag is True, Check if today is the date which configured in 'reminder_schedule' OR the warning date is before but no warning send yet, then send warning to organizer. Run every day at 00:40 AM

For testing, I have created 5 endpoints to trigger the schedule task:

  • Billing invoice collect: /api/v1/billing-testing/invoice-collect
  • Billing invoice notification: /api/v1/billing-testing/invoice-notification
  • Billing charge on invoice: /api/v1/billing-testing/invoice-charge
  • Billing invoice payment retry: /api/v1/billing-testing/invoice-retry
  • Billing invoice warning: /api/v1/billing-testing/invoice-warning

Summary by Sourcery

Implement a scheduled task to collect billing invoices for events monthly using Celery Beat, and introduce a new 'BillingInvoice' model to manage billing data. Update the deployment configuration to support the new task scheduling.

New Features:

  • Introduce a scheduled task using Celery Beat to collect billing invoices for all events on a monthly basis.

Enhancements:

  • Add a new model 'BillingInvoice' to manage billing data, including fields for status, amount, currency, and reminder schedules.

Build:

  • Update the Celery Beat schedule to include a new task for monthly billing collection.

Deployment:

  • Add a new supervisor configuration for running the Celery Beat process in the deployment setup.

Copy link

sourcery-ai bot commented Oct 29, 2024

Reviewer's Guide by Sourcery

This PR implements a monthly billing system for collecting invoice payments using Celery Beat scheduler. The implementation includes a new BillingInvoice model, Stripe integration for payment processing, automated billing collection tasks, and a billing settings UI for organizers to manage their payment information.

Sequence diagram for monthly billing collection task

sequenceDiagram
    participant CeleryBeat
    participant Task as monthly_billing_collect
    participant DB as Database
    participant Stripe
    CeleryBeat->>Task: Trigger task
    Task->>DB: Query for events and orders
    alt Orders exist
        Task->>DB: Create BillingInvoice
        Task->>Stripe: Process payment
    else No orders
        Task->>Task: Log no orders
    end
    Task->>DB: Save BillingInvoice
Loading

Class diagram for new BillingInvoice model

classDiagram
    class BillingInvoice {
        +String STATUS_PENDING = "n"
        +String STATUS_PAID = "p"
        +String STATUS_EXPIRED = "e"
        +String STATUS_CANCELED = "c"
        +String status
        +Decimal amount
        +String currency
        +Decimal ticket_fee
        +String payment_method
        +DateTime paid_datetime
        +Text note
        +Date monthly_bill
        +DateTime created_at
        +String created_by
        +DateTime updated_at
        +String updated_by
        +DateTime last_reminder_datetime
        +DateTime next_reminder_datetime
        +Array reminder_schedule
        +Boolean reminder_enabled
        +String stripe_payment_intent_id
    }
Loading

Class diagram for OrganizerBillingModel

classDiagram
    class OrganizerBillingModel {
        +String primary_contact_name
        +String primary_contact_email
        +String company_or_organization_name
        +String address_line_1
        +String address_line_2
        +String city
        +String zip_code
        +String country
        +String preferred_language
        +String tax_id
        +String stripe_customer_id
        +String stripe_payment_method_id
        +String stripe_setup_intent_id
    }
Loading

File-Level Changes

Change Details Files
Implement monthly billing collection system using Celery Beat
  • Add monthly_billing_collect task to process billing for all events on the 1st of each month
  • Create billing_invoice_notification task to send invoice notifications
  • Add process_auto_billing_charge task to handle automatic payment collection
  • Implement retry_failed_payment task for retrying failed payments
  • Add check_billing_status_for_warning task to monitor billing status and send warnings
src/pretix/eventyay_common/tasks.py
src/pretix/settings.py
Create billing data models and database migrations
  • Create BillingInvoice model with fields for status, amount, currency, and payment tracking
  • Add OrganizerBillingModel for storing organizer billing information
  • Create database migrations for new models
src/pretix/base/models/billing.py
src/pretix/base/models/organizer.py
src/pretix/base/migrations/0003_create_billing_invoice_table.py
src/pretix/base/migrations/0003_alter_cachedcombinedticket_id_alter_cachedticket_id_and_more.py
src/pretix/base/migrations/0004_billinginvoice_stripe_payment_intent_id_and_more.py
Implement Stripe payment integration
  • Add Stripe API utilities for payment processing
  • Implement webhook handling for Stripe payment events
  • Create setup intent and payment intent handling
  • Add payment method management functionality
src/pretix/control/stripe_utils.py
src/pretix/control/stripe.py
Create billing settings UI for organizers
  • Add billing settings form with general information and payment details
  • Implement credit card management interface
  • Create billing invoice PDF generation functionality
  • Add JavaScript handlers for payment form interactions
src/pretix/control/templates/pretixcontrol/organizers/billing.html
src/pretix/static/billing/js/billing.js
src/pretix/static/billing/css/billing.css
src/pretix/eventyay_common/billing_invoice.py
Update deployment configuration
  • Add Celery Beat configuration for scheduled tasks
  • Create supervisor configuration for Celery Beat process
  • Update URL routing for new billing endpoints
deployment/docker/supervisord/pretixbeat.conf
deployment/docker/pretix.bash
src/pretix/urls.py
src/pretix/control/urls.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time. You can also use
    this command to specify where the summary should be inserted.

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @lcduong - I've reviewed your changes and found some issues that need to be addressed.

Blocking issues:

  • The task is subtracting 3 months instead of 1 month when calculating last_month_date (link)

Overall Comments:

  • Consider adding a unique constraint or using select_for_update() to prevent race conditions when creating billing invoices for the same event/month combination
  • The monthly_billing_collect task is looking back 3 months (relativedelta(months=3)) instead of 1 month - is this intentional? This seems incorrect for monthly billing
Here's what I looked at during the review
  • 🔴 General issues: 1 blocking issue, 2 other issues
  • 🟢 Security: all looks good
  • 🟢 Testing: all looks good
  • 🟡 Complexity: 1 issue found
  • 🟢 Documentation: all looks good

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

first_day_of_current_month = today.replace(day=1)
logger.info("Start - running task to collect billing on: %s", first_day_of_current_month)
# Get the last month by subtracting one month from today
last_month_date = (first_day_of_current_month - relativedelta(months=3)).date()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The task is subtracting 3 months instead of 1 month when calculating last_month_date

This will cause billing to be calculated for the wrong month. Should be relativedelta(months=1) to bill for the previous month.

src/pretix/eventyay_common/tasks.py Show resolved Hide resolved
src/pretix/eventyay_common/tasks.py Show resolved Hide resolved
@@ -117,3 +122,107 @@
'Content-Type': 'application/json',
}
return headers


@shared_task(bind=True, max_retries=5, default_retry_delay=60) # Retries up to 5 times with a 60-second delay
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the event billing logic into a separate function to reduce complexity of the monthly billing collection task.

The monthly_billing_collect function's complexity can be reduced by extracting the per-event billing logic while maintaining the same error handling strategy. Here's a suggested refactor:

def process_event_billing(event, organizer, last_month_date, ticket_rate, today):
    billing_invoice = BillingInvoice.objects.filter(
        event=event, 
        monthly_bill=last_month_date,
        organizer=organizer
    )
    if billing_invoice:
        logger.debug("Billing invoice already created for event: %s", event.name)
        return

    total_amount = calculate_total_amount_on_monthly(event, last_month_date)
    tickets_fee = calculate_ticket_fee(total_amount, ticket_rate)

    billing_invoice = BillingInvoice(
        organizer=organizer,
        event=event,
        amount=total_amount,
        currency=event.currency,
        ticket_fee=tickets_fee,
        monthly_bill=last_month_date,
        reminder_schedule=settings.BILLING_REMINDER_SCHEDULE,
        created_at=today,
        created_by=settings.PRETIX_EMAIL_NONE_VALUE,
        updated_at=today,
        updated_by=settings.PRETIX_EMAIL_NONE_VALUE
    )
    billing_invoice.next_reminder_datetime = get_next_reminder_datetime(
        settings.BILLING_REMINDER_SCHEDULE)
    billing_invoice.save()

@shared_task(bind=True, max_retries=5, default_retry_delay=60)
def monthly_billing_collect(self):
    try:
        today = datetime.today()
        first_day_of_current_month = today.replace(day=1)
        last_month_date = (first_day_of_current_month - relativedelta(months=3)).date()

        gs = GlobalSettingsObject()
        ticket_rate = gs.settings.get('ticket_rate') or 2.5

        for organizer in Organizer.objects.all():
            for event in Event.objects.filter(organizer=organizer):
                try:
                    logger.info("Collecting billing for event: %s", event.name)
                    process_event_billing(event, organizer, last_month_date, ticket_rate, today)
                except Exception as e:
                    logger.error('Error processing billing for event %s: %s', event.slug, e)
                    continue

    except Exception as e:
        logger.error('Error in monthly billing collection: %s', e)
        self.retry(exc=e)

This refactoring:

  1. Extracts event billing logic to a focused function
  2. Maintains the two-level error handling strategy
  3. Reduces nesting depth
  4. Improves readability while keeping all functionality intact

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let introduce only 1 DB migration file, to not inflate the number.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I solved it, only 1 migration file now for new table creation.

@@ -735,6 +736,31 @@
('pretix.plugins.banktransfer.*', {'queue': 'background'}),
],)

CELERY_BEAT_SCHEDULE = {
Copy link
Member

@hongquan hongquan Nov 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tasks which run periodically, better implemented with systemd timer. Benefit:

  • Beside the time point which you scheduled, you can run the task any time, (when you need to test, or there is a need to run task one more time) with the command sudo systemctl start task-name.service.

  • Easily to temporary disable the schedule, for example when you found that there is bug and you want to disable the task until you fix, by sudo systemctl stop task-name.timer.

  • Logs are collected by journald (part of systemd), with rich feature to filter and view logs.

  • The program only runs when needed. If implemented as Celery Beat, there will be a process always running with the whole code of our application, which is wasteful.

@mariobehling
Copy link
Member

As discussed elsewhere:

Thanks for your suggestion, so instead of using systemd, can we apply django-celery-beat (https://django-celery-beat.readthedocs.io/en/latest/) which will store periodic tasks into database, and we can implement an API (or setting page) to enable/disable it.

@mariobehling mariobehling merged commit eca6fad into fossasia:development Nov 21, 2024
4 of 5 checks passed
self.retry(exc=e)
except self.MaxRetriesExceededError:
logger.error("Max retries exceeded for billing collect.")
except Exception as e:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't catch general Exception class. Catch concrete ones.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants