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