17 changed files with 1549 additions and 68 deletions
@ -0,0 +1,59 @@ |
|||
import json |
|||
from typing import List, Dict, Union, Any |
|||
from app import create_app, db |
|||
from models import Payments, PaymentBatch, SinglePayments, PaymentPlans |
|||
from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET |
|||
from services import log_activity |
|||
|
|||
|
|||
splynx = Splynx(url=SPLYNX_URL, key=SPLYNX_KEY, secret=SPLYNX_SECRET) |
|||
|
|||
results = { |
|||
"deleted": 0, |
|||
"error": 0 |
|||
} |
|||
|
|||
|
|||
|
|||
def delete_splynx_invoices(splynx_id: int, payintent: str) -> List[Dict[str, Any]]: |
|||
#result = splynx.get(url=f"/api/2.0/admin/finance/invoices?main_attributes[customer_id]={splynx_id}&main_attributes[status]=paid&main_attributes[date]=2025-08-21") |
|||
params = { |
|||
'main_attributes': { |
|||
'customer_id': splynx_id, |
|||
'field_1': payintent |
|||
}, |
|||
} |
|||
query_string = splynx.build_splynx_query_params(params) |
|||
result = splynx.get(url=f"/api/2.0/admin/finance/payments?{query_string}") |
|||
|
|||
print(f"Count: {len(result)} - {json.dumps(result,indent=2)}") |
|||
|
|||
delete = splynx.delete(url=f"/api/2.0/admin/finance/payments/{result[0]['id']}") |
|||
if delete: |
|||
results['deleted'] += 1 |
|||
details = f"Deleted Splynx Payment ID: {result[0]['id']} for Splynx Customer: {splynx_id}" |
|||
else: |
|||
results['error'] += 1 |
|||
details = f"Error deleting Splynx Payment ID: {result[0]['id']} for Splynx Customer: {splynx_id}" |
|||
log_activity( |
|||
user_id=1, |
|||
action="DELETE_SPLYNX_PAYMENT", |
|||
entity_type="Script", |
|||
details=details |
|||
) |
|||
|
|||
|
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
app = create_app() |
|||
|
|||
|
|||
with app.app_context(): |
|||
payments = db.session.query(Payments).filter(Payments.Refund == True).all() |
|||
|
|||
for pay in payments: |
|||
delete_splynx_invoices(pay.Splynx_ID, pay.Payment_Intent) |
|||
|
|||
print(json.dumps(results,indent=2)) |
|||
|
|||
@ -0,0 +1,60 @@ |
|||
"""Add new Charge and Refund features |
|||
|
|||
Revision ID: 1af0e892bd5d |
|||
Revises: 3252db86eaae |
|||
Create Date: 2025-08-21 14:19:38.943110 |
|||
|
|||
""" |
|||
from alembic import op |
|||
import sqlalchemy as sa |
|||
|
|||
|
|||
# revision identifiers, used by Alembic. |
|||
revision = '1af0e892bd5d' |
|||
down_revision = '3252db86eaae' |
|||
branch_labels = None |
|||
depends_on = None |
|||
|
|||
|
|||
def upgrade(): |
|||
# ### commands auto generated by Alembic - please adjust! ### |
|||
with op.batch_alter_table('PaymentPlans', schema=None) as batch_op: |
|||
batch_op.drop_column('Day') |
|||
|
|||
with op.batch_alter_table('Payments', schema=None) as batch_op: |
|||
batch_op.add_column(sa.Column('Stripe_Charge_ID', sa.String(), nullable=True)) |
|||
batch_op.add_column(sa.Column('Refund', sa.Boolean(), nullable=True)) |
|||
batch_op.add_column(sa.Column('Refund_JSON', sa.Text(), nullable=True)) |
|||
batch_op.add_column(sa.Column('Stripe_Refund_ID', sa.String(), nullable=True)) |
|||
batch_op.add_column(sa.Column('Stripe_Refund_Created', sa.DateTime(), nullable=True)) |
|||
|
|||
with op.batch_alter_table('SinglePayments', schema=None) as batch_op: |
|||
batch_op.add_column(sa.Column('Stripe_Charge_ID', sa.String(), nullable=True)) |
|||
batch_op.add_column(sa.Column('Refund', sa.Boolean(), nullable=True)) |
|||
batch_op.add_column(sa.Column('Refund_JSON', sa.Text(), nullable=True)) |
|||
batch_op.add_column(sa.Column('Stripe_Refund_ID', sa.String(), nullable=True)) |
|||
batch_op.add_column(sa.Column('Stripe_Refund_Created', sa.DateTime(), nullable=True)) |
|||
|
|||
# ### end Alembic commands ### |
|||
|
|||
|
|||
def downgrade(): |
|||
# ### commands auto generated by Alembic - please adjust! ### |
|||
with op.batch_alter_table('SinglePayments', schema=None) as batch_op: |
|||
batch_op.drop_column('Stripe_Refund_Created') |
|||
batch_op.drop_column('Stripe_Refund_ID') |
|||
batch_op.drop_column('Refund_JSON') |
|||
batch_op.drop_column('Refund') |
|||
batch_op.drop_column('Stripe_Charge_ID') |
|||
|
|||
with op.batch_alter_table('Payments', schema=None) as batch_op: |
|||
batch_op.drop_column('Stripe_Refund_Created') |
|||
batch_op.drop_column('Stripe_Refund_ID') |
|||
batch_op.drop_column('Refund_JSON') |
|||
batch_op.drop_column('Refund') |
|||
batch_op.drop_column('Stripe_Charge_ID') |
|||
|
|||
with op.batch_alter_table('PaymentPlans', schema=None) as batch_op: |
|||
batch_op.add_column(sa.Column('Day', sa.VARCHAR(length=50), autoincrement=False, nullable=True)) |
|||
|
|||
# ### end Alembic commands ### |
|||
@ -0,0 +1,35 @@ |
|||
import stripe |
|||
import json |
|||
from app import create_app, db |
|||
from models import * |
|||
from sqlalchemy import and_ |
|||
api_key = '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' |
|||
stripe.api_key = api_key |
|||
|
|||
|
|||
|
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
app = create_app() |
|||
print(f"api_key: {api_key}") |
|||
|
|||
|
|||
with app.app_context(): |
|||
to_check = { |
|||
"pay": db.session.query(Payments).filter(and_(Payments.Stripe_Charge_ID == None, Payments.Payment_Intent != None)).all(), |
|||
"singlepay": db.session.query(SinglePayments).filter(and_(SinglePayments.Stripe_Charge_ID == None, SinglePayments.Payment_Intent != None)).all(), |
|||
} |
|||
|
|||
for key, value in to_check.items(): |
|||
|
|||
|
|||
for pi in value: |
|||
print(f"PI: {pi.Payment_Intent}") |
|||
res = stripe.PaymentIntent.retrieve( |
|||
pi.Payment_Intent, |
|||
expand=['latest_charge.balance_transaction'] |
|||
) |
|||
if res.get('latest_charge') and res.get('latest_charge').get('id').startswith('ch_'): |
|||
pi.Stripe_Charge_ID = res.get('latest_charge').get('id') |
|||
db.session.commit() |
|||
@ -0,0 +1,76 @@ |
|||
|
|||
import pymysql |
|||
import sys |
|||
import json |
|||
import random |
|||
import threading |
|||
import logging |
|||
from sqlalchemy import func |
|||
from concurrent.futures import ThreadPoolExecutor, as_completed |
|||
from datetime import datetime |
|||
from typing import List, Dict, Union, Any |
|||
from stripe_payment_processor import StripePaymentProcessor |
|||
from config import Config |
|||
from app import create_app, db |
|||
from models import Payments, PaymentBatch, SinglePayments, PaymentPlans |
|||
from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET |
|||
from services import ( |
|||
log_script_start, log_script_completion, log_batch_created, |
|||
log_payment_intent_followup |
|||
) |
|||
|
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
app = create_app() |
|||
|
|||
with app.app_context(): |
|||
# Find customer_ids that appear more than once |
|||
duplicate_customer_ids = db.session.query( |
|||
Payments.Stripe_Customer_ID |
|||
).group_by( |
|||
Payments.Stripe_Customer_ID |
|||
).having( |
|||
func.count(Payments.Stripe_Customer_ID) > 1 |
|||
).all() |
|||
|
|||
# Get the actual duplicate records |
|||
duplicate_records = db.session.query(Payments).filter( |
|||
Payments.Stripe_Customer_ID.in_([row[0] for row in duplicate_customer_ids]) |
|||
).order_by(Payments.Stripe_Customer_ID).all() |
|||
i = 0 |
|||
has_charge = 0 |
|||
for a in duplicate_records: |
|||
i += 1 |
|||
#print(a.Stripe_Customer_ID) |
|||
if a.Stripe_Charge_ID != None: |
|||
has_charge += 1 |
|||
|
|||
print(i, i/2, has_charge, has_charge/2) |
|||
|
|||
|
|||
ranked_duplicates = db.session.query( |
|||
Payments.id, |
|||
func.row_number().over( |
|||
partition_by=Payments.Stripe_Customer_ID, |
|||
order_by=Payments.id |
|||
).label('rn') |
|||
).filter( |
|||
Payments.Stripe_Customer_ID.in_([row[0] for row in duplicate_customer_ids]) |
|||
).subquery() |
|||
|
|||
# Get only the first record (rn = 1) for each customer_id |
|||
first_duplicates = db.session.query(Payments).join( |
|||
ranked_duplicates, Payments.id == ranked_duplicates.c.id |
|||
).filter(ranked_duplicates.c.rn == 1).order_by(Payments.Stripe_Customer_ID).all() |
|||
|
|||
i = 0 |
|||
has_charge = 0 |
|||
for a in first_duplicates: |
|||
i += 1 |
|||
#print(a.id, a.Splynx_ID) |
|||
print(a.Stripe_Charge_ID) |
|||
if a.Stripe_Charge_ID != None: |
|||
has_charge += 1 |
|||
|
|||
print(i, has_charge) |
|||
@ -0,0 +1,214 @@ |
|||
|
|||
import pymysql |
|||
import sys |
|||
import json |
|||
import random |
|||
import threading |
|||
import logging |
|||
import stripe |
|||
from sqlalchemy import func |
|||
from concurrent.futures import ThreadPoolExecutor, as_completed |
|||
from datetime import datetime |
|||
from typing import List, Dict, Union, Any |
|||
from stripe_payment_processor import StripePaymentProcessor |
|||
from config import Config |
|||
from app import create_app, db |
|||
from models import Payments, PaymentBatch, SinglePayments, PaymentPlans |
|||
from splynx import Splynx, SPLYNX_URL, SPLYNX_KEY, SPLYNX_SECRET |
|||
|
|||
|
|||
api_key = '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' |
|||
stripe.api_key = api_key |
|||
|
|||
|
|||
|
|||
def issue_refund_for_payment(payment_record, amount=None, reason=None): |
|||
""" |
|||
Issue a refund for a payment record using its Stripe_Charge_ID |
|||
|
|||
Args: |
|||
payment_record: The Payments model instance |
|||
amount: Optional - amount to refund in cents. If None, refunds full amount |
|||
reason: Optional - reason for refund ('duplicate', 'fraudulent', 'requested_by_customer') |
|||
|
|||
Returns: |
|||
dict: Success status and refund details |
|||
""" |
|||
try: |
|||
# Check if payment has a charge ID |
|||
if not payment_record.Stripe_Charge_ID: |
|||
return { |
|||
'success': False, |
|||
'error': 'No Stripe Charge ID found for this payment', |
|||
'payment_id': payment_record.id |
|||
} |
|||
|
|||
# Check if already refunded |
|||
if payment_record.Refund: |
|||
return { |
|||
'success': False, |
|||
'error': 'Payment already marked as refunded', |
|||
'payment_id': payment_record.id |
|||
} |
|||
|
|||
# Create refund parameters |
|||
refund_params = { |
|||
'charge': payment_record.Stripe_Charge_ID |
|||
} |
|||
|
|||
if amount: |
|||
refund_params['amount'] = amount |
|||
|
|||
if reason: |
|||
refund_params['reason'] = reason |
|||
|
|||
# Issue the refund |
|||
refund = stripe.Refund.create(**refund_params) |
|||
|
|||
if refund['status'] == "succeeded": |
|||
|
|||
# Update the payment record |
|||
payment_record.Refund = True |
|||
payment_record.Stripe_Refund_ID = refund.id |
|||
payment_record.Stripe_Refund_Created = datetime.fromtimestamp(refund.created) |
|||
payment_record.Refund_JSON = json.dumps(refund) |
|||
|
|||
db.session.commit() |
|||
|
|||
return { |
|||
'success': True, |
|||
'refund_id': refund.id, |
|||
'amount_refunded': refund.amount, |
|||
'payment_id': payment_record.id, |
|||
'refund': refund |
|||
} |
|||
else: |
|||
payment_record.Refund = False |
|||
|
|||
# Commit the changes |
|||
db.session.commit() |
|||
return { |
|||
'success': False, |
|||
'payment_id': payment_record.id, |
|||
'refund': refund |
|||
} |
|||
|
|||
except stripe.error.InvalidRequestError as e: |
|||
if "has already been refunded" in str(e) and payment_record.Refund != True: |
|||
payment_record.Refund = True |
|||
payment_record.Stripe_Refund_Created = datetime.now() |
|||
db.session.commit() |
|||
return { |
|||
'success': False, |
|||
'error': f'Stripe error: {str(e)}', |
|||
'payment_id': payment_record.id |
|||
} |
|||
except Exception as e: |
|||
db.session.rollback() |
|||
return { |
|||
'success': False, |
|||
'error': f'Unexpected error: {str(e)}', |
|||
'payment_id': payment_record.id |
|||
} |
|||
|
|||
if __name__ == "__main__": |
|||
app = create_app() |
|||
|
|||
with app.app_context(): |
|||
# Find customer_ids that appear more than once |
|||
duplicate_customer_ids = db.session.query( |
|||
Payments.Stripe_Customer_ID |
|||
).group_by( |
|||
Payments.Stripe_Customer_ID |
|||
).having( |
|||
func.count(Payments.Stripe_Customer_ID) > 1 |
|||
).all() |
|||
|
|||
# Get the actual duplicate records |
|||
duplicate_records = db.session.query(Payments).filter( |
|||
Payments.Stripe_Customer_ID.in_([row[0] for row in duplicate_customer_ids]) |
|||
).order_by(Payments.Stripe_Customer_ID).all() |
|||
i = 0 |
|||
has_charge = 0 |
|||
for a in duplicate_records: |
|||
i += 1 |
|||
#print(a.Stripe_Customer_ID) |
|||
if a.Stripe_Charge_ID != None: |
|||
has_charge += 1 |
|||
|
|||
print(i, i/2, has_charge, has_charge/2) |
|||
|
|||
|
|||
from sqlalchemy import func, and_ |
|||
|
|||
# Step 1: Find customer_ids that appear more than once |
|||
duplicate_customer_ids = db.session.query( |
|||
Payments.Stripe_Customer_ID |
|||
).group_by( |
|||
Payments.Stripe_Customer_ID |
|||
).having( |
|||
func.count(Payments.Stripe_Customer_ID) > 1 |
|||
).all() |
|||
|
|||
# Step 2: Find customer_ids that already have any refunded payments |
|||
customers_with_refunds = db.session.query( |
|||
Payments.Stripe_Customer_ID |
|||
).filter( |
|||
and_( |
|||
Payments.Stripe_Customer_ID.in_([row[0] for row in duplicate_customer_ids]), |
|||
Payments.Refund == True |
|||
) |
|||
).distinct().all() |
|||
|
|||
# Step 3: Get customer_ids to process (duplicates minus those with existing refunds) |
|||
customers_to_refund = [ |
|||
customer_id for customer_id in [row[0] for row in duplicate_customer_ids] |
|||
if customer_id not in [row[0] for row in customers_with_refunds] |
|||
] |
|||
|
|||
print(f"Total duplicate customers: {len(duplicate_customer_ids)}") |
|||
print(f"Customers already with refunds: {len(customers_with_refunds)}") |
|||
print(f"Customers eligible for refund: {len(customers_to_refund)}") |
|||
|
|||
# Step 4: Get the first record for each eligible customer |
|||
if customers_to_refund: |
|||
ranked_duplicates = db.session.query( |
|||
Payments.id, |
|||
func.row_number().over( |
|||
partition_by=Payments.Stripe_Customer_ID, |
|||
order_by=Payments.id |
|||
).label('rn') |
|||
).filter( |
|||
Payments.Stripe_Customer_ID.in_(customers_to_refund) |
|||
).subquery() |
|||
|
|||
# Get only the first record (rn = 1) for each customer_id |
|||
first_duplicates = db.session.query(Payments).join( |
|||
ranked_duplicates, Payments.id == ranked_duplicates.c.id |
|||
).filter(ranked_duplicates.c.rn == 1).order_by(Payments.Stripe_Customer_ID).all() |
|||
else: |
|||
first_duplicates = [] |
|||
|
|||
# Now process refunds safely |
|||
results = [] |
|||
for payment in first_duplicates: |
|||
if payment.Stripe_Charge_ID: |
|||
print(f"Safe to refund - Payment ID: {payment.id}, Customer: {payment.Stripe_Customer_ID}, Charge: {payment.Stripe_Charge_ID}") |
|||
result = issue_refund_for_payment( |
|||
payment, |
|||
reason='duplicate' |
|||
) |
|||
|
|||
results.append(result) |
|||
|
|||
if result['success']: |
|||
print(f"✓ Refund successful: {result['refund_id']}") |
|||
else: |
|||
print(f"✗ Refund failed: {result['error']}") |
|||
else: |
|||
print(f"Skipping Payment ID {payment.id} - No Stripe Charge ID") |
|||
|
|||
print("\n\n\n") |
|||
print("="*60) |
|||
print("\n RESULTS\n\n") |
|||
print(json.dumps(results,indent=2)) |
|||
@ -0,0 +1,594 @@ |
|||
{% extends "base.html" %} |
|||
|
|||
{% block title %}Payment #{{ payment.id }} - Plutus{% endblock %} |
|||
|
|||
{% block content %} |
|||
<nav class="breadcrumb" aria-label="breadcrumbs"> |
|||
<ul> |
|||
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li> |
|||
<li><a href="{{ url_for('main.batch_list') }}">Payment Batches</a></li> |
|||
<li><a href="{{ url_for('main.batch_detail', batch_id=payment.PaymentBatch_ID) }}">Batch #{{ payment.PaymentBatch_ID }}</a></li> |
|||
<li class="is-active"><a href="#" aria-current="page">Payment #{{ payment.id }}</a></li> |
|||
</ul> |
|||
</nav> |
|||
|
|||
<div class="level"> |
|||
<div class="level-left"> |
|||
<div> |
|||
<h1 class="title">Batch Payment #{{ payment.id }}</h1> |
|||
<p class="subtitle">Batch #{{ payment.PaymentBatch_ID }} • Processed: {{ payment.Created.strftime('%Y-%m-%d %H:%M:%S') if payment.Created else 'Unknown' }}</p> |
|||
</div> |
|||
</div> |
|||
<div class="level-right"> |
|||
<div class="field is-grouped"> |
|||
{% if payment.Refund != True and payment.Success == True %} |
|||
<div class="control"> |
|||
<button class="button is-warning" id="refundBtn" onclick="showRefundModal()"> |
|||
<span class="icon"><i class="fas fa-undo"></i></span> |
|||
<span>Process Refund</span> |
|||
</button> |
|||
</div> |
|||
{% endif %} |
|||
{% if payment.PI_FollowUp %} |
|||
<div class="control"> |
|||
<button class="button is-info" id="checkIntentBtn" onclick="checkPaymentIntent()"> |
|||
<span class="icon"><i class="fas fa-sync-alt"></i></span> |
|||
<span>Check Status</span> |
|||
</button> |
|||
</div> |
|||
{% endif %} |
|||
<div class="control"> |
|||
<a class="button is-light" href="{{ url_for('main.batch_detail', batch_id=payment.PaymentBatch_ID) }}"> |
|||
<span class="icon"><i class="fas fa-arrow-left"></i></span> |
|||
<span>Back to Batch</span> |
|||
</a> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Payment Status Banner --> |
|||
<div class="box"> |
|||
<div class="level"> |
|||
<div class="level-left"> |
|||
<div class="level-item"> |
|||
{% if payment.Refund == True %} |
|||
<span class="icon is-large" style="color: #9370db;"> |
|||
<i class="fas fa-undo fa-2x"></i> |
|||
</span> |
|||
{% elif payment.Success == True %} |
|||
<span class="icon is-large has-text-success"> |
|||
<i class="fas fa-check-circle fa-2x"></i> |
|||
</span> |
|||
{% elif payment.Success == False %} |
|||
<span class="icon is-large has-text-danger"> |
|||
<i class="fas fa-times-circle fa-2x"></i> |
|||
</span> |
|||
{% else %} |
|||
<span class="icon is-large has-text-warning"> |
|||
<i class="fas fa-clock fa-2x"></i> |
|||
</span> |
|||
{% endif %} |
|||
</div> |
|||
<div class="level-item"> |
|||
<div> |
|||
{% if payment.Refund == True %} |
|||
<h2 class="title is-4 mb-2" style="color: #9370db;">Payment Refunded</h2> |
|||
<p class="has-text-grey">This payment has been refunded to the customer.</p> |
|||
{% if payment.Stripe_Refund_Created %} |
|||
<p class="has-text-grey is-size-7">Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</p> |
|||
{% endif %} |
|||
{% elif payment.Success == True %} |
|||
<h2 class="title is-4 has-text-success mb-2">Payment Successful</h2> |
|||
<p class="has-text-grey">This payment has been completed successfully.</p> |
|||
{% elif payment.Success == False %} |
|||
<h2 class="title is-4 has-text-danger mb-2">Payment Failed</h2> |
|||
<p class="has-text-grey">This payment could not be completed.</p> |
|||
{% else %} |
|||
<h2 class="title is-4 has-text-warning mb-2">Payment Pending</h2> |
|||
<p class="has-text-grey">This payment is still being processed.</p> |
|||
{% endif %} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Payment Details --> |
|||
<div class="columns"> |
|||
<div class="column is-half"> |
|||
<div class="box"> |
|||
<h3 class="title is-5"> |
|||
<span class="icon"><i class="fas fa-info-circle"></i></span> |
|||
Payment Information |
|||
</h3> |
|||
|
|||
<table class="table is-fullwidth"> |
|||
<tbody> |
|||
<tr> |
|||
<td><strong>Payment ID</strong></td> |
|||
<td>#{{ payment.id }}</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Batch ID</strong></td> |
|||
<td> |
|||
<a href="{{ url_for('main.batch_detail', batch_id=payment.PaymentBatch_ID) }}" class="has-text-weight-semibold"> |
|||
#{{ payment.PaymentBatch_ID }} |
|||
</a> |
|||
</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Splynx Customer ID</strong></td> |
|||
<td> |
|||
{% if payment.Splynx_ID %} |
|||
<a href="https://billing.interphone.com.au/admin/customers/view?id={{ payment.Splynx_ID }}" |
|||
target="_blank">{{ payment.Splynx_ID }}</a> |
|||
{% else %} |
|||
- |
|||
{% endif %} |
|||
</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Stripe Customer ID</strong></td> |
|||
<td><code>{{ payment.Stripe_Customer_ID or '-' }}</code></td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Payment Intent</strong></td> |
|||
<td><code>{{ payment.Payment_Intent or '-' }}</code></td> |
|||
</tr> |
|||
{% if payment.Stripe_Charge_ID %} |
|||
<tr> |
|||
<td><strong>Stripe Charge ID</strong></td> |
|||
<td><code>{{ payment.Stripe_Charge_ID }}</code></td> |
|||
</tr> |
|||
{% endif %} |
|||
{% if payment.Stripe_Refund_ID %} |
|||
<tr> |
|||
<td><strong>Stripe Refund ID</strong></td> |
|||
<td><code>{{ payment.Stripe_Refund_ID }}</code></td> |
|||
</tr> |
|||
{% endif %} |
|||
<tr> |
|||
<td><strong>Payment Method</strong></td> |
|||
<td> |
|||
{% if payment.Payment_Method %} |
|||
<span class="tag is-info">{{ payment.Payment_Method }}</span> |
|||
{% else %} |
|||
- |
|||
{% endif %} |
|||
</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Created</strong></td> |
|||
<td>{{ payment.Created.strftime('%Y-%m-%d %H:%M:%S') if payment.Created else '-' }}</td> |
|||
</tr> |
|||
{% if payment.PaymentPlan_ID %} |
|||
<tr> |
|||
<td><strong>Payment Plan</strong></td> |
|||
<td> |
|||
<a href="{{ url_for('main.payment_plans_detail', plan_id=payment.PaymentPlan_ID) }}" class="has-text-weight-semibold"> |
|||
Plan #{{ payment.PaymentPlan_ID }} |
|||
</a> |
|||
</td> |
|||
</tr> |
|||
{% endif %} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="column is-half"> |
|||
<div class="box"> |
|||
<h3 class="title is-5"> |
|||
<span class="icon"><i class="fas fa-dollar-sign"></i></span> |
|||
Financial Details |
|||
</h3> |
|||
|
|||
<table class="table is-fullwidth"> |
|||
<tbody> |
|||
<tr> |
|||
<td><strong>Payment Amount</strong></td> |
|||
<td><strong class="has-text-success">{{ payment.Payment_Amount | currency }}</strong></td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Stripe Fee</strong></td> |
|||
<td>{{ payment.Fee_Stripe | currency if payment.Fee_Stripe else '-' }}</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Tax Fee</strong></td> |
|||
<td>{{ payment.Fee_Tax | currency if payment.Fee_Tax else '-' }}</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Total Fees</strong></td> |
|||
<td>{{ payment.Fee_Total | currency if payment.Fee_Total else '-' }}</td> |
|||
</tr> |
|||
{% if payment.Fee_Total and payment.Payment_Amount %} |
|||
<tr> |
|||
<td><strong>Net Amount</strong></td> |
|||
<td><strong>{{ (payment.Payment_Amount - payment.Fee_Total) | currency }}</strong></td> |
|||
</tr> |
|||
{% endif %} |
|||
</tbody> |
|||
</table> |
|||
|
|||
{% if payment.PI_FollowUp %} |
|||
<div class="notification is-warning is-light"> |
|||
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span> |
|||
<strong>Follow-up Required:</strong> This payment requires additional processing. |
|||
{% if payment.PI_Last_Check %} |
|||
<br><small>Last checked: {{ payment.PI_Last_Check.strftime('%Y-%m-%d %H:%M:%S') }}</small> |
|||
{% endif %} |
|||
</div> |
|||
{% endif %} |
|||
|
|||
{% if payment.Refund == True %} |
|||
<div class="notification is-light" style="background-color: #f8f4ff; border-color: #9370db;"> |
|||
<span class="icon" style="color: #9370db;"><i class="fas fa-undo"></i></span> |
|||
<strong style="color: #9370db;">Refund Processed:</strong> This payment has been refunded. |
|||
{% if payment.Stripe_Refund_Created %} |
|||
<br><small>Refunded: {{ payment.Stripe_Refund_Created.strftime('%Y-%m-%d %H:%M:%S') }}</small> |
|||
{% endif %} |
|||
</div> |
|||
{% endif %} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Error Information --> |
|||
{% if payment.Error %} |
|||
<div class="box"> |
|||
<h3 class="title is-5 has-text-danger"> |
|||
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span> |
|||
Error Information |
|||
</h3> |
|||
|
|||
<div class="notification is-danger is-light"> |
|||
<pre>{{ payment.Error }}</pre> |
|||
</div> |
|||
</div> |
|||
{% endif %} |
|||
|
|||
<!-- JSON Data --> |
|||
<div class="columns"> |
|||
{% if payment.PI_JSON %} |
|||
<div class="column"> |
|||
<div class="box"> |
|||
<h3 class="title is-5"> |
|||
<span class="icon"><i class="fas fa-code"></i></span> |
|||
Payment Intent JSON |
|||
</h3> |
|||
|
|||
<div class="field is-grouped"> |
|||
<div class="control"> |
|||
<button class="button is-small is-info" onclick="copyFormattedJSON('pi-json-content')"> |
|||
<span class="icon"><i class="fas fa-copy"></i></span> |
|||
<span>Copy JSON</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<pre class="is-size-7"><code>{{ payment.PI_JSON | format_json }}</code></pre> |
|||
<div id="pi-json-content" style="display: none;">{{ payment.PI_JSON | format_json }}</div> |
|||
</div> |
|||
</div> |
|||
{% endif %} |
|||
|
|||
{% if payment.PI_FollowUp_JSON %} |
|||
<div class="column"> |
|||
<div class="box"> |
|||
<h3 class="title is-5"> |
|||
<span class="icon"><i class="fas fa-redo"></i></span> |
|||
Follow-up JSON |
|||
</h3> |
|||
|
|||
<div class="field is-grouped"> |
|||
<div class="control"> |
|||
<button class="button is-small is-primary" onclick="copyFormattedJSON('followup-json-content')"> |
|||
<span class="icon"><i class="fas fa-copy"></i></span> |
|||
<span>Copy JSON</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<pre class="is-size-7"><code>{{ payment.PI_FollowUp_JSON | format_json }}</code></pre> |
|||
<div id="followup-json-content" style="display: none;">{{ payment.PI_FollowUp_JSON | format_json }}</div> |
|||
</div> |
|||
</div> |
|||
{% endif %} |
|||
|
|||
{% if payment.Refund_JSON %} |
|||
<div class="column"> |
|||
<div class="box"> |
|||
<h3 class="title is-5"> |
|||
<span class="icon" style="color: #9370db;"><i class="fas fa-undo"></i></span> |
|||
Refund JSON |
|||
</h3> |
|||
|
|||
<div class="field is-grouped"> |
|||
<div class="control"> |
|||
<button class="button is-small" style="border-color: #9370db; color: #9370db;" onclick="copyFormattedJSON('refund-json-content')"> |
|||
<span class="icon"><i class="fas fa-copy"></i></span> |
|||
<span>Copy JSON</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<pre class="is-size-7"><code>{{ payment.Refund_JSON | format_json }}</code></pre> |
|||
<div id="refund-json-content" style="display: none;">{{ payment.Refund_JSON | format_json }}</div> |
|||
</div> |
|||
</div> |
|||
{% endif %} |
|||
</div> |
|||
|
|||
<!-- Refund Modal --> |
|||
<div class="modal" id="refundModal"> |
|||
<div class="modal-background"></div> |
|||
<div class="modal-card"> |
|||
<header class="modal-card-head" style="background-color: #9370db;"> |
|||
<p class="modal-card-title has-text-white"> |
|||
<span class="icon"><i class="fas fa-undo"></i></span> |
|||
Process Refund |
|||
</p> |
|||
<button class="delete" aria-label="close" onclick="hideModal('refundModal')"></button> |
|||
</header> |
|||
<section class="modal-card-body"> |
|||
<div class="has-text-centered py-4"> |
|||
<span class="icon is-large mb-4" style="color: #9370db;"> |
|||
<i class="fas fa-undo fa-3x"></i> |
|||
</span> |
|||
<div class="content"> |
|||
<h4 class="title is-5">Confirm Refund</h4> |
|||
<p>Are you sure you want to process a refund for this payment?</p> |
|||
<p><strong>Payment Amount:</strong> {{ payment.Payment_Amount | currency }}</p> |
|||
<p><strong>Customer:</strong> {{ payment.Splynx_ID }}</p> |
|||
|
|||
<div class="field"> |
|||
<label class="label">Refund Reason</label> |
|||
<div class="control"> |
|||
<div class="select is-fullwidth"> |
|||
<select id="refundReason"> |
|||
<option value="requested_by_customer">Requested by Customer</option> |
|||
<option value="duplicate">Duplicate Payment</option> |
|||
<option value="fraudulent">Fraudulent</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
<footer class="modal-card-foot is-justify-content-center"> |
|||
<button class="button" style="border-color: #9370db; color: #9370db;" onclick="processRefund()" id="processRefundBtn"> |
|||
<span class="icon"><i class="fas fa-undo"></i></span> |
|||
<span>Process Refund</span> |
|||
</button> |
|||
<button class="button" onclick="hideModal('refundModal')">Cancel</button> |
|||
</footer> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Success Modal --> |
|||
<div class="modal" id="successModal"> |
|||
<div class="modal-background"></div> |
|||
<div class="modal-card"> |
|||
<header class="modal-card-head has-background-success"> |
|||
<p class="modal-card-title has-text-white"> |
|||
<span class="icon"><i class="fas fa-check-circle"></i></span> |
|||
Success |
|||
</p> |
|||
</header> |
|||
<section class="modal-card-body"> |
|||
<div class="has-text-centered py-4"> |
|||
<span class="icon is-large has-text-success mb-4"> |
|||
<i class="fas fa-check-circle fa-3x"></i> |
|||
</span> |
|||
<div id="successMessage" class="content"> |
|||
<!-- Success details will be populated here --> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
<footer class="modal-card-foot is-justify-content-center"> |
|||
<button class="button is-success" onclick="closeSuccessModal()"> |
|||
<span class="icon"><i class="fas fa-check"></i></span> |
|||
<span>Refresh Page</span> |
|||
</button> |
|||
</footer> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Error Modal --> |
|||
<div class="modal" id="errorModal"> |
|||
<div class="modal-background" onclick="hideModal('errorModal')"></div> |
|||
<div class="modal-card"> |
|||
<header class="modal-card-head has-background-danger"> |
|||
<p class="modal-card-title has-text-white"> |
|||
<span class="icon"><i class="fas fa-exclamation-circle"></i></span> |
|||
Error |
|||
</p> |
|||
<button class="delete" aria-label="close" onclick="hideModal('errorModal')"></button> |
|||
</header> |
|||
<section class="modal-card-body"> |
|||
<div class="has-text-centered py-4"> |
|||
<span class="icon is-large has-text-danger mb-4"> |
|||
<i class="fas fa-exclamation-circle fa-3x"></i> |
|||
</span> |
|||
<div id="errorDetails" class="content"> |
|||
<!-- Error details will be populated here --> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
<footer class="modal-card-foot is-justify-content-center"> |
|||
<button class="button is-danger" onclick="hideModal('errorModal')"> |
|||
<span class="icon"><i class="fas fa-times"></i></span> |
|||
<span>Close</span> |
|||
</button> |
|||
</footer> |
|||
</div> |
|||
</div> |
|||
|
|||
<script> |
|||
function checkPaymentIntent() { |
|||
const btn = document.getElementById('checkIntentBtn'); |
|||
const originalText = btn.innerHTML; |
|||
|
|||
// Disable button and show loading |
|||
btn.disabled = true; |
|||
btn.innerHTML = '<span class="icon"><i class="fas fa-spinner fa-spin"></i></span><span>Checking...</span>'; |
|||
|
|||
// Make API call to check payment intent |
|||
fetch(`/payment/check-intent/{{ payment.id }}`, { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
} |
|||
}) |
|||
.then(response => response.json()) |
|||
.then(data => { |
|||
if (data.success) { |
|||
if (data.payment_succeeded) { |
|||
showSuccessModal(` |
|||
<h4 class="title is-5">Payment Completed Successfully!</h4> |
|||
<p>The payment has been processed and completed.</p> |
|||
<p><strong>Status:</strong> ${data.status}</p> |
|||
`); |
|||
} else { |
|||
showSuccessModal(` |
|||
<h4 class="title is-5">Status Updated</h4> |
|||
<p>Payment status has been updated.</p> |
|||
<p><strong>Current Status:</strong> ${data.status}</p> |
|||
`); |
|||
} |
|||
} else { |
|||
showErrorModal(data.error || 'Failed to check payment intent status'); |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
console.error('Error checking payment intent:', error); |
|||
showErrorModal('Failed to check payment intent status. Please try again.'); |
|||
}) |
|||
.finally(() => { |
|||
// Re-enable button |
|||
btn.disabled = false; |
|||
btn.innerHTML = originalText; |
|||
}); |
|||
} |
|||
|
|||
function showRefundModal() { |
|||
document.getElementById('refundModal').classList.add('is-active'); |
|||
} |
|||
|
|||
function processRefund() { |
|||
const btn = document.getElementById('processRefundBtn'); |
|||
const originalText = btn.innerHTML; |
|||
const reason = document.getElementById('refundReason').value; |
|||
|
|||
// Disable button and show loading |
|||
btn.disabled = true; |
|||
btn.innerHTML = '<span class="icon"><i class="fas fa-spinner fa-spin"></i></span><span>Processing...</span>'; |
|||
|
|||
// Make API call to process refund |
|||
fetch(`/payment/refund/{{ payment.id }}`, { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
body: JSON.stringify({ |
|||
reason: reason |
|||
}) |
|||
}) |
|||
.then(response => response.json()) |
|||
.then(data => { |
|||
hideModal('refundModal'); |
|||
if (data.success) { |
|||
showSuccessModal(` |
|||
<h4 class="title is-5">Refund Processed Successfully!</h4> |
|||
<p>The refund has been processed and sent to Stripe.</p> |
|||
<p><strong>Refund ID:</strong> ${data.refund_id}</p> |
|||
<p><strong>Amount:</strong> ${data.amount_refunded}</p> |
|||
`); |
|||
} else { |
|||
showErrorModal(data.error || 'Failed to process refund'); |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
hideModal('refundModal'); |
|||
console.error('Error processing refund:', error); |
|||
showErrorModal('Failed to process refund. Please try again.'); |
|||
}) |
|||
.finally(() => { |
|||
// Re-enable button |
|||
btn.disabled = false; |
|||
btn.innerHTML = originalText; |
|||
}); |
|||
} |
|||
|
|||
function showSuccessModal(message) { |
|||
document.getElementById('successMessage').innerHTML = message; |
|||
document.getElementById('successModal').classList.add('is-active'); |
|||
} |
|||
|
|||
function showErrorModal(errorMessage) { |
|||
document.getElementById('errorDetails').innerHTML = `<p>${errorMessage}</p>`; |
|||
document.getElementById('errorModal').classList.add('is-active'); |
|||
} |
|||
|
|||
function hideModal(modalId) { |
|||
document.getElementById(modalId).classList.remove('is-active'); |
|||
} |
|||
|
|||
function closeSuccessModal() { |
|||
hideModal('successModal'); |
|||
// Refresh the page to show updated data |
|||
window.location.reload(); |
|||
} |
|||
|
|||
// Copy to clipboard functionality |
|||
function copyFormattedJSON(elementId) { |
|||
const element = document.getElementById(elementId); |
|||
const text = element.textContent || element.innerText; |
|||
|
|||
navigator.clipboard.writeText(text).then(function() { |
|||
// Show temporary success message |
|||
const button = event.target.closest('button'); |
|||
const originalText = button.innerHTML; |
|||
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>'; |
|||
button.classList.add('is-success'); |
|||
|
|||
setTimeout(function() { |
|||
button.innerHTML = originalText; |
|||
button.classList.remove('is-success'); |
|||
}, 2000); |
|||
}).catch(function(err) { |
|||
console.error('Failed to copy text: ', err); |
|||
// Fallback for older browsers |
|||
const textArea = document.createElement('textarea'); |
|||
textArea.value = text; |
|||
document.body.appendChild(textArea); |
|||
textArea.select(); |
|||
try { |
|||
document.execCommand('copy'); |
|||
const button = event.target.closest('button'); |
|||
const originalText = button.innerHTML; |
|||
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>'; |
|||
button.classList.add('is-success'); |
|||
|
|||
setTimeout(function() { |
|||
button.innerHTML = originalText; |
|||
button.classList.remove('is-success'); |
|||
}, 2000); |
|||
} catch (fallbackErr) { |
|||
console.error('Fallback copy failed: ', fallbackErr); |
|||
} |
|||
document.body.removeChild(textArea); |
|||
}); |
|||
} |
|||
|
|||
// Close modal on Escape key |
|||
document.addEventListener('keydown', function(event) { |
|||
if (event.key === 'Escape') { |
|||
const activeModals = document.querySelectorAll('.modal.is-active'); |
|||
activeModals.forEach(modal => modal.classList.remove('is-active')); |
|||
} |
|||
}); |
|||
</script> |
|||
{% endblock %} |
|||
@ -0,0 +1,26 @@ |
|||
#!/usr/bin/env python3 |
|||
""" |
|||
WSGI entry point for Plutus Flask application. |
|||
|
|||
This file is used by gunicorn and other WSGI servers to serve the application. |
|||
""" |
|||
|
|||
import sys |
|||
import os |
|||
|
|||
# Add the application directory to Python path |
|||
current_dir = os.path.dirname(os.path.abspath(__file__)) |
|||
sys.path.insert(0, current_dir) |
|||
|
|||
# Import the create_app function from app.py |
|||
from app import create_app |
|||
|
|||
# Create the application instance |
|||
application = create_app() |
|||
|
|||
# Gunicorn looks for 'application' by default, but we can also provide gunicorn_app |
|||
gunicorn_app = application |
|||
|
|||
if __name__ == "__main__": |
|||
# For development - run with python wsgi.py |
|||
application.run(debug=False, host='0.0.0.0', port=5000) |
|||
Loading…
Reference in new issue