SHARE
    TWEET
    J2897

    PayPal Activity CSV to GnuCash-ready splits (GBP, USD, FX reference)

    Jan 3rd, 2026
    1,814
    0
    Never
    Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
    Python 6.26 KB | None | 0 0
    1. #!/usr/bin/env python3
    2. r"""
    3. Split a PayPal "Activity Download" CSV into:
    4. 1) GBP purchases/refunds (excluding funding and FX rows)
    5. 2) USD purchases/refunds (excluding FX rows)
    6. 3) FX reference table (GBP->USD conversions for USD purchases)
    7. Works with the PayPal CSV format that has columns like:
    8. Date, Time, Time Zone, Description, Currency, Gross, Fee, Net, Balance, Transaction ID, Reference Txn ID, Name, ...
    9. Usage (Windows):
    10. py -3 paypal_split_by_currency.py "C:\path\PayPal.CSV"
    11. Outputs files next to the input CSV by default.
    12. """
    13. import argparse
    14. import csv
    15. from collections import Counter
    16. from pathlib import Path
    17. EXCLUDE_GBP_DESCRIPTIONS = {
    18. "Bank Deposit to PP Account",
    19. "User Initiated Withdrawal",
    20. "General Currency Conversion",
    21. }
    22. EXCLUDE_USD_DESCRIPTIONS = {
    23. "General Currency Conversion",
    24. }
    25. USD_PAYMENT_DESCRIPTIONS = {
    26. "Pre-approved Payment Bill User Payment",
    27. "Express Checkout Payment",
    28. "General Payment",
    29. }
    30. def parse_float(s: str) -> float:
    31. s = (s or "").strip()
    32. if s == "":
    33. return 0.0
    34. return float(s)
    35. def read_rows(csv_path: Path):
    36. with csv_path.open("r", encoding="utf-8-sig", newline="") as f:
    37. reader = csv.DictReader(f)
    38. rows = list(reader)
    39. if not rows:
    40. raise SystemExit("CSV appears to be empty.")
    41. return reader.fieldnames, rows
    42. def write_rows(out_path: Path, fieldnames, rows):
    43. out_path.parent.mkdir(parents=True, exist_ok=True)
    44. with out_path.open("w", encoding="utf-8", newline="") as f:
    45. w = csv.DictWriter(f, fieldnames=fieldnames)
    46. w.writeheader()
    47. w.writerows(rows)
    48. def main():
    49. ap = argparse.ArgumentParser()
    50. ap.add_argument("csv", type=Path, help="PayPal activity CSV file")
    51. ap.add_argument("--outdir", type=Path, default=None, help="Output directory (default: same folder as input)")
    52. ap.add_argument("--prefix", type=str, default=None, help="Filename prefix (default: input filename stem)")
    53. args = ap.parse_args()
    54. csv_path: Path = args.csv
    55. outdir: Path = args.outdir if args.outdir else csv_path.parent
    56. prefix: str = args.prefix if args.prefix else csv_path.stem
    57. fieldnames, rows = read_rows(csv_path)
    58. # Basic sanity check for columns we rely on
    59. required = {"Currency", "Description", "Net", "Transaction ID", "Reference Txn ID", "Date", "Time"}
    60. missing_cols = required - set(fieldnames or [])
    61. if missing_cols:
    62. raise SystemExit(f"Missing expected columns: {', '.join(sorted(missing_cols))}")
    63. # Split for import
    64. gbp_rows = [
    65. r for r in rows
    66. if (r.get("Currency") == "GBP") and (r.get("Description") not in EXCLUDE_GBP_DESCRIPTIONS)
    67. ]
    68. usd_rows = [
    69. r for r in rows
    70. if (r.get("Currency") == "USD") and (r.get("Description") not in EXCLUDE_USD_DESCRIPTIONS)
    71. ]
    72. # Build quick lookup for FX components keyed by purchase transaction id
    73. # For a USD purchase with Transaction ID = PID, PayPal records:
    74. # GBP "Bank Deposit to PP Account" Reference Txn ID == PID
    75. # GBP "General Currency Conversion" Reference Txn ID == PID (Net negative)
    76. # USD "General Currency Conversion" Reference Txn ID == PID (Net positive)
    77. fx_lookup = {}
    78. for r in rows:
    79. ref = (r.get("Reference Txn ID") or "").strip()
    80. if not ref:
    81. continue
    82. fx_lookup.setdefault(ref, []).append(r)
    83. fx_ref_rows = []
    84. fx_missing = 0
    85. for r in rows:
    86. if r.get("Currency") != "USD":
    87. continue
    88. if r.get("Description") not in USD_PAYMENT_DESCRIPTIONS:
    89. continue
    90. pid = (r.get("Transaction ID") or "").strip()
    91. if not pid:
    92. continue
    93. bundle = fx_lookup.get(pid, [])
    94. gbp_deposit = next((x for x in bundle if x.get("Currency") == "GBP" and x.get("Description") == "Bank Deposit to PP Account"), None)
    95. gbp_conv = next((x for x in bundle if x.get("Currency") == "GBP" and x.get("Description") == "General Currency Conversion"), None)
    96. usd_conv = next((x for x in bundle if x.get("Currency") == "USD" and x.get("Description") == "General Currency Conversion"), None)
    97. if not (gbp_deposit and gbp_conv and usd_conv):
    98. fx_missing += 1
    99. continue
    100. gbp_topup = parse_float(gbp_deposit.get("Net"))
    101. gbp_converted = abs(parse_float(gbp_conv.get("Net")))
    102. usd_received = parse_float(usd_conv.get("Net"))
    103. usd_spent = abs(parse_float(r.get("Net")))
    104. rate = (usd_received / gbp_converted) if gbp_converted else 0.0
    105. merchant = (r.get("Name") or "").strip() or "(blank name)"
    106. fx_ref_rows.append({
    107. "Date": r.get("Date"),
    108. "Time": r.get("Time"),
    109. "Merchant": merchant,
    110. "GBP topup (bank->PayPal GBP)": f"{gbp_topup:.2f}",
    111. "GBP converted (PayPal GBP->USD)": f"{gbp_converted:.2f}",
    112. "USD received (PayPal USD)": f"{usd_received:.2f}",
    113. "USD spent at merchant": f"{usd_spent:.2f}",
    114. "Implied rate (USD per GBP)": f"{rate:.6f}",
    115. "Purchase TxID": pid,
    116. })
    117. # Output paths
    118. gbp_out = outdir / f"{prefix}_GBP_purchases.csv"
    119. usd_out = outdir / f"{prefix}_USD_purchases.csv"
    120. fx_out = outdir / f"{prefix}_FX_reference.csv"
    121. write_rows(gbp_out, fieldnames, gbp_rows)
    122. write_rows(usd_out, fieldnames, usd_rows)
    123. fx_fields = ["Date","Time","Merchant","GBP topup (bank->PayPal GBP)","GBP converted (PayPal GBP->USD)","USD received (PayPal USD)","USD spent at merchant","Implied rate (USD per GBP)","Purchase TxID"]
    124. write_rows(fx_out, fx_fields, fx_ref_rows)
    125. # Summary
    126. ccy_counts = Counter(r.get("Currency") for r in rows)
    127. desc_counts = Counter(r.get("Description") for r in rows)
    128. print("Input:", csv_path)
    129. print("Rows:", len(rows), "| Currencies:", dict(ccy_counts))
    130. print("Wrote:")
    131. print(" ", gbp_out, f"({len(gbp_rows)} rows)")
    132. print(" ", usd_out, f"({len(usd_rows)} rows)")
    133. print(" ", fx_out, f"({len(fx_ref_rows)} FX rows; {fx_missing} missing bundles skipped)")
    134. print("Top descriptions:", ", ".join([f"{k}={v}" for k,v in desc_counts.most_common(6)]))
    135. if __name__ == "__main__":
    136. main()
    Advertisement
    Public Pastes
    We use cookies for various purposes including analytics. By continuing to use Pastebin, you agree to our use of cookies as described in the Cookies Policy. OK, I Understand
    Not a member of Pastebin yet?
    Sign Up, it unlocks many cool features!

    AltStyle によって変換されたページ (->オリジナル) /