"""Demo seed — runs automatically on first startup when DEMO_MODE=true.""" from __future__ import annotations import hashlib import uuid from datetime import date, datetime, timezone from decimal import Decimal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import encrypt_field, hash_password from app.db.models import ( Account, Asset, Budget, Category, InvestmentHolding, InvestmentTransaction, ManualCGTDisposal, Payslip, TaxProfile, Transaction, User, ) DEMO_EMAIL = "demo@mymidas.app" DEMO_PASSWORD = "demo123" def _now() -> datetime: return datetime.now(timezone.utc) def _h(*parts) -> str: return hashlib.sha256("|".join(str(p) for p in parts).encode()).hexdigest() async def is_seeded(db: AsyncSession) -> bool: return bool(await db.scalar(select(User).where(User.email == DEMO_EMAIL))) async def seed_demo(db: AsyncSession) -> None: if await is_seeded(db): return now = _now() # ── User ────────────────────────────────────────────────────────────── user = User( id=uuid.uuid4(), email=DEMO_EMAIL, password_hash=hash_password(DEMO_PASSWORD), display_name="Alex Demo", base_currency="GBP", theme="obsidian", locale="en-GB", created_at=now, updated_at=now, ) db.add(user) await db.flush() uid = user.id # ── System categories ────────────────────────────────────────────────── res = await db.execute(select(Category).where(Category.is_system == True)) cats = {c.name: c for c in res.scalars().all()} def cid(name: str): return cats[name].id if name in cats else None # ── Accounts ────────────────────────────────────────────────────────── def mk_acc(name, institution, kind, balance, color, currency="GBP", credit_limit=None): return Account( id=uuid.uuid4(), user_id=uid, name_enc=encrypt_field(name), institution_enc=encrypt_field(institution) if institution else None, type=kind, currency=currency, current_balance=Decimal(str(balance)), credit_limit=Decimal(str(credit_limit)) if credit_limit else None, is_active=True, include_in_net_worth=True, color=color, meta={}, created_at=now, updated_at=now, ) monzo = mk_acc("Monzo Current Account", "Monzo", "checking", "2847.32", "#ff6b35") marcus = mk_acc("Marcus Savings", "Goldman Sachs", "savings", "6234.50", "#22c55e") amex = mk_acc("Amex Gold", "American Express", "credit_card", "-342.18", "#f59e0b", credit_limit=5000) freetrade = mk_acc("Freetrade Stocks & Shares ISA", "Freetrade", "investment", "0", "#6366f1") for acc in [monzo, marcus, amex, freetrade]: db.add(acc) await db.flush() # ── Transaction helpers ──────────────────────────────────────────────── def txn(account, txn_type, amount, desc, merchant, cat_name, txn_date, is_recurring=False, recurring_rule=None): return Transaction( id=uuid.uuid4(), user_id=uid, account_id=account.id, type=txn_type, status="cleared", amount=Decimal(str(amount)), amount_base=Decimal(str(amount)), currency="GBP", base_currency="GBP", date=txn_date, description_enc=encrypt_field(desc), merchant_enc=encrypt_field(merchant) if merchant else None, category_id=cid(cat_name), tags=[], is_recurring=is_recurring, recurring_rule=recurring_rule, attachment_refs=[], import_hash=_h(uid, account.id, txn_date, amount, desc), meta={}, created_at=now, updated_at=now, ) def rr(freq, amount, next_exp, last_paid): return { "frequency": freq, "typical_amount": float(amount), "next_expected": next_exp, "last_paid": last_paid, "confidence": 0.97, "manually_set": False, "detected_at": now.isoformat(), } def next_month(y, m): return (y + 1, 1) if m == 12 else (y, m + 1) def d(y, m, day): return date(y, m, min(day, [31,28,31,30,31,30,31,31,30,31,30,31][m-1])) txns = [] # ── Monthly recurring transactions (Oct 2025 – Mar 2026) ────────────── months = [(2025, 10), (2025, 11), (2025, 12), (2026, 1), (2026, 2), (2026, 3)] for year, month in months: ny, nm = next_month(year, month) n1 = d(ny, nm, 1).isoformat() this1 = d(year, month, 1).isoformat() # Salary txns.append(txn(monzo, "income", 3489.00, "Demo Corp Ltd - Salary", "Demo Corp Ltd", "Salary", d(year, month, 1), True, rr("monthly", 3489.00, n1, this1))) # Rent txns.append(txn(monzo, "expense", -1250.00, "DIRECT DEBIT ANYLETS PROPERTY MGMT", "Anylets", "Rent / Mortgage", d(year, month, 1), True, rr("monthly", -1250.00, n1, this1))) # Council tax txns.append(txn(monzo, "expense", -145.00, "DIRECT DEBIT LONDON BOROUGH COUNCIL TAX", "London Borough", "Council Tax", d(year, month, 1), True, rr("monthly", -145.00, n1, this1))) # Internet txns.append(txn(monzo, "expense", -35.00, "DIRECT DEBIT BT BROADBAND", "BT", "Internet", d(year, month, 2), True, rr("monthly", -35.00, d(ny, nm, 2).isoformat(), d(year, month, 2).isoformat()))) # Phone txns.append(txn(monzo, "expense", -25.00, "DIRECT DEBIT EE MOBILE", "EE", "Phone", d(year, month, 3), True, rr("monthly", -25.00, d(ny, nm, 3).isoformat(), d(year, month, 3).isoformat()))) # Energy (variable) energy = [-102, -98, -115, -108, -103, -95][months.index((year, month))] txns.append(txn(monzo, "expense", energy, "DIRECT DEBIT OVO ENERGY", "OVO Energy", "Electricity", d(year, month, 5), True, rr("monthly", energy, d(ny, nm, 5).isoformat(), d(year, month, 5).isoformat()))) # Subscriptions for sub_desc, sub_merch, sub_amt in [ ("DIRECT DEBIT NETFLIX", "Netflix", -17.99), ("DIRECT DEBIT SPOTIFY", "Spotify", -11.99), ]: txns.append(txn(monzo, "expense", sub_amt, sub_desc, sub_merch, "Subscriptions", d(year, month, 5), True, rr("monthly", sub_amt, d(ny, nm, 5).isoformat(), d(year, month, 5).isoformat()))) for sub_desc, sub_merch, sub_amt in [ ("DIRECT DEBIT AMAZON PRIME", "Amazon Prime", -8.99), ("DIRECT DEBIT APPLE ICLOUD", "Apple iCloud", -2.99), ("DIRECT DEBIT GITHUB", "GitHub", -3.99), ]: txns.append(txn(monzo, "expense", sub_amt, sub_desc, sub_merch, "Subscriptions", d(year, month, 6), True, rr("monthly", sub_amt, d(ny, nm, 6).isoformat(), d(year, month, 6).isoformat()))) # Gym txns.append(txn(monzo, "expense", -35.00, "DIRECT DEBIT PUREGYM", "PureGym", "Gym", d(year, month, 7), True, rr("monthly", -35.00, d(ny, nm, 7).isoformat(), d(year, month, 7).isoformat()))) # TfL top-ups (2/month) tfl = [(-40, 17, -30, 29), (-35, 15, -40, 28), (-40, 16, -35, 30), (-40, 17, -30, 28), (-40, 14, -35, 27), (-40, 17, -30, 29)][months.index((year, month))] txns.append(txn(monzo, "expense", tfl[0], "TfL Travel Top-Up", "Transport for London", "Public Transport", d(year, month, tfl[1]))) txns.append(txn(monzo, "expense", tfl[2], "TfL Travel Top-Up", "Transport for London", "Public Transport", d(year, month, tfl[3]))) # Transfer to savings out_id = uuid.uuid4() inn_id = uuid.uuid4() sav_rr = rr("monthly", -300.00, d(ny, nm, 15).isoformat(), d(year, month, 15).isoformat()) txns.append(Transaction( id=out_id, user_id=uid, account_id=monzo.id, transfer_account_id=marcus.id, type="transfer", status="cleared", amount=Decimal("-300.00"), amount_base=Decimal("-300.00"), currency="GBP", base_currency="GBP", date=d(year, month, 15), description_enc=encrypt_field("Transfer to Marcus Savings"), category_id=cid("Transfer"), tags=[], is_recurring=True, recurring_rule=sav_rr, attachment_refs=[], import_hash=_h(uid, monzo.id, year, month, "transfer_out"), meta={}, created_at=now, updated_at=now, )) txns.append(Transaction( id=inn_id, user_id=uid, account_id=marcus.id, transfer_account_id=monzo.id, type="transfer", status="cleared", amount=Decimal("300.00"), amount_base=Decimal("300.00"), currency="GBP", base_currency="GBP", date=d(year, month, 15), description_enc=encrypt_field("Transfer from Monzo"), category_id=cid("Transfer"), tags=[], is_recurring=True, recurring_rule=sav_rr, attachment_refs=[], import_hash=_h(uid, marcus.id, year, month, "transfer_in"), meta={}, created_at=now, updated_at=now, )) # ── Groceries ───────────────────────────────────────────────────────── groceries = [ (2025,10, 9, -71.43, "Tesco"), (2025,10, 16, -63.21, "Sainsbury's"), (2025,10, 23, -58.76, "Tesco"), (2025,10, 28, -47.30, "Lidl"), (2025,10, 30, -65.80, "Tesco"), (2025,11, 8, -68.92, "Tesco"), (2025,11, 15, -72.10, "Sainsbury's"), (2025,11, 21, -55.40, "Lidl"), (2025,11, 27, -61.33, "Tesco"), (2025,12, 6, -82.14, "Waitrose"), (2025,12, 13, -65.20, "Sainsbury's"), (2025,12, 20, -71.50, "Tesco"), (2025,12, 27, -90.30, "Waitrose"), (2026, 1, 10, -66.45, "Tesco"), (2026, 1, 17, -59.80, "Lidl"), (2026, 1, 24, -74.20, "Sainsbury's"), (2026, 1, 31, -68.90, "Tesco"), (2026, 2, 7, -73.15, "Tesco"), (2026, 2, 14, -61.40, "Sainsbury's"), (2026, 2, 21, -55.90, "Lidl"), (2026, 2, 28, -80.45, "Waitrose"), (2026, 3, 7, -69.30, "Tesco"), (2026, 3, 14, -58.75, "Lidl"), (2026, 3, 21, -76.20, "Sainsbury's"), (2026, 3, 28, -65.80, "Tesco"), ] for y, m, day, amt, merch in groceries: txns.append(txn(monzo, "expense", amt, f"{merch} Groceries", merch, "Groceries", date(y, m, day))) # ── Eating out ──────────────────────────────────────────────────────── eating_out = [ (2025,10, 14, -42.00, "Wagamama"), (2025,10, 19, -28.50, "Deliveroo"), (2025,10, 26, -55.00, "Dishoom"), (2025,11, 8, -35.00, "Nando's"), (2025,11, 22, -48.50, "Carluccio's"), (2025,11, 28, -22.00, "Deliveroo"), (2025,12, 12, -65.00, "Gaucho"), (2025,12, 19, -38.00, "Pizza Express"), (2025,12, 23, -52.00, "Dishoom"), (2026, 1, 10, -29.00, "Deliveroo"), (2026, 1, 17, -45.00, "Wagamama"), (2026, 1, 25, -38.50, "Nando's"), (2026, 2, 6, -42.00, "Dishoom"), (2026, 2, 14, -78.00, "Restaurant"), (2026, 2, 22, -31.50, "Deliveroo"), (2026, 3, 8, -38.00, "Wagamama"), (2026, 3, 15, -25.00, "Deliveroo"), (2026, 3, 22, -47.00, "Nando's"), ] for y, m, day, amt, merch in eating_out: txns.append(txn(monzo, "expense", amt, merch, merch, "Eating Out", date(y, m, day))) # ── Coffee ──────────────────────────────────────────────────────────── coffee = [ (2025,10, 12, -4.80, "Costa Coffee"), (2025,10, 17, -8.50, "Pret a Manger"), (2025,10, 24, -5.20, "Starbucks"), (2025,11, 5, -4.50, "Costa Coffee"), (2025,11, 13, -5.20, "Starbucks"), (2025,11, 20, -4.80, "Pret a Manger"), (2025,11, 27, -6.50, "Blank Street"), (2025,12, 4, -5.20, "Starbucks"), (2025,12, 11, -4.80, "Costa Coffee"), (2025,12, 18, -5.00, "Pret a Manger"), (2026, 1, 9, -4.50, "Costa Coffee"), (2026, 1, 16, -5.20, "Starbucks"), (2026, 1, 23, -4.80, "Pret a Manger"), (2026, 2, 5, -5.20, "Starbucks"), (2026, 2, 12, -4.80, "Costa Coffee"), (2026, 2, 20, -5.50, "Blank Street"), (2026, 2, 27, -4.80, "Pret a Manger"), (2026, 3, 5, -4.80, "Costa Coffee"), (2026, 3, 12, -5.20, "Starbucks"), (2026, 3, 19, -4.50, "Pret a Manger"), ] for y, m, day, amt, merch in coffee: txns.append(txn(monzo, "expense", amt, merch, merch, "Coffee", date(y, m, day))) # ── Shopping / other ────────────────────────────────────────────────── shopping = [ (2025,10, 21, -34.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), (2025,10, 31, -12.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), (2025,10, 25, -89.00, monzo, "ASOS", "ASOS", "Clothing"), (2025,11, 14, -89.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), (2025,11, 18, -85.00, monzo, "Dental Practice", "Dental Practice", "Healthcare"), (2025,11, 28, -22.50, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), (2025,12, 3, -156.00, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), (2025,12, 5, -650.00, monzo, "EasyJet", "EasyJet", "Holidays"), (2025,12, 5, -380.00, monzo, "Booking.com", "Booking.com", "Holidays"), (2025,12, 15, -210.00, monzo, "Amazon", "AMAZON.CO.UK", "Gifts"), (2025,12, 16, -85.00, monzo, "John Lewis", "John Lewis", "Gifts"), (2025,12, 18, -45.00, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), (2025,12, 20, -120.00, monzo, "Airbnb", "Airbnb", "Holidays"), (2026, 1, 12, -28.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), (2026, 1, 26, -67.00, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), (2026, 2, 10, -44.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), (2026, 2, 18, -75.00, monzo, "Uniqlo", "Uniqlo", "Clothing"), (2026, 2, 25, -19.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), (2026, 3, 8, -38.50, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), (2026, 3, 22, -55.00, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), # Car insurance (October, annual) (2025,10, 15, -485.00, monzo, "Aviva", "AVIVA CAR INSURANCE ANNUAL", "Car Insurance"), # Amex purchases (2025,10, 20, -320.00, amex, "M&S", "MARKS AND SPENCER", "Groceries"), (2025,11, 8, -145.00, amex, "Restaurant", "RESTAURANT GORDON RAMSAY", "Eating Out"), (2025,12, 22, -89.00, amex, "Apple", "APPLE STORE", "Other Expense"), (2026, 1, 25, -215.00, amex, "Sofitel", "SOFITEL HOTEL", "Holidays"), (2026, 2, 14, -178.50, amex, "Selfridges", "SELFRIDGES", "Clothing"), (2026, 3, 8, -95.00, amex, "Harvey Nichols", "HARVEY NICHOLS", "Personal Care"), # Savings interest (quarterly) (2025,10, 28, 12.50, marcus, "Goldman Sachs", "Marcus Savings Interest", "Investment Income"), (2026, 1, 28, 13.20, marcus, "Goldman Sachs", "Marcus Savings Interest", "Investment Income"), ] for row in shopping: y, m, day, amt, acc, merch, desc, cat = row txn_type = "income" if amt > 0 else "expense" txns.append(txn(acc, txn_type, amt, desc, merch, cat, date(y, m, day))) for t in txns: db.add(t) await db.flush() # ── Budgets ─────────────────────────────────────────────────────────── budget_defs = [ ("Groceries", 300.00, "Groceries"), ("Eating Out", 200.00, "Eating Out"), ("Transport", 100.00, "Public Transport"), ("Entertainment", 80.00, "Entertainment"), ("Utilities", 180.00, "Electricity"), ("Subscriptions", 60.00, "Subscriptions"), ("Shopping", 200.00, "Clothing"), ] for bname, amount, cat_name in budget_defs: cat_id = cid(cat_name) if not cat_id: continue db.add(Budget( id=uuid.uuid4(), user_id=uid, category_id=cat_id, name=bname, amount=Decimal(str(amount)), currency="GBP", period="monthly", start_date=date(2025, 10, 1), rollover=False, alert_threshold=Decimal("80"), is_active=True, created_at=now, updated_at=now, )) await db.flush() # ── Assets ──────────────────────────────────────────────────────────── vwrp = Asset( id=uuid.uuid4(), symbol="VWRP.L", name="Vanguard FTSE All-World UCITS ETF", type="etf", currency="GBP", exchange="LSE", data_source="yahoo_finance", last_price=Decimal("107.50"), last_price_at=now, price_change_24h=Decimal("0.85"), is_active=True, created_at=now, updated_at=now, ) aapl = Asset( id=uuid.uuid4(), symbol="AAPL", name="Apple Inc.", type="stock", currency="USD", exchange="NASDAQ", data_source="yahoo_finance", last_price=Decimal("212.50"), last_price_at=now, price_change_24h=Decimal("-0.45"), is_active=True, created_at=now, updated_at=now, ) btc = Asset( id=uuid.uuid4(), symbol="BTC-USD", name="Bitcoin", type="crypto", currency="USD", exchange=None, data_source="coingecko", last_price=Decimal("84500.00"), last_price_at=now, price_change_24h=Decimal("2.30"), is_active=True, created_at=now, updated_at=now, ) for a in [vwrp, aapl, btc]: db.add(a) await db.flush() # ── Holdings ────────────────────────────────────────────────────────── # Quantities match investment transactions below — do not double-count. vwrp_h = InvestmentHolding( id=uuid.uuid4(), user_id=uid, account_id=freetrade.id, asset_id=vwrp.id, quantity=Decimal("50"), avg_cost_basis=Decimal("101.21"), currency="GBP", created_at=now, updated_at=now, ) aapl_h = InvestmentHolding( id=uuid.uuid4(), user_id=uid, account_id=freetrade.id, asset_id=aapl.id, quantity=Decimal("10"), avg_cost_basis=Decimal("228.50"), currency="USD", created_at=now, updated_at=now, ) btc_h = InvestmentHolding( id=uuid.uuid4(), user_id=uid, account_id=freetrade.id, asset_id=btc.id, quantity=Decimal("0.05"), avg_cost_basis=Decimal("69500.00"), currency="USD", created_at=now, updated_at=now, ) for h in [vwrp_h, aapl_h, btc_h]: db.add(h) await db.flush() # ── Investment transactions (history only — holding quantities already set) ── inv_txns = [ # VWRP: 3 buys totalling 50 shares InvestmentTransaction( id=uuid.uuid4(), user_id=uid, holding_id=vwrp_h.id, type="buy", quantity=Decimal("15"), price=Decimal("98.50"), fees=Decimal("0"), total_amount=Decimal("1477.50"), currency="GBP", date=date(2025, 10, 5), created_at=now, ), InvestmentTransaction( id=uuid.uuid4(), user_id=uid, holding_id=vwrp_h.id, type="buy", quantity=Decimal("20"), price=Decimal("102.30"), fees=Decimal("0"), total_amount=Decimal("2046.00"), currency="GBP", date=date(2025, 12, 10), created_at=now, ), InvestmentTransaction( id=uuid.uuid4(), user_id=uid, holding_id=vwrp_h.id, type="buy", quantity=Decimal("15"), price=Decimal("105.80"), fees=Decimal("0"), total_amount=Decimal("1587.00"), currency="GBP", date=date(2026, 2, 14), created_at=now, ), # VWRP dividend InvestmentTransaction( id=uuid.uuid4(), user_id=uid, holding_id=vwrp_h.id, type="dividend", quantity=Decimal("0"), price=Decimal("0"), fees=Decimal("0"), total_amount=Decimal("62.50"), currency="GBP", date=date(2025, 12, 20), created_at=now, ), # AAPL buy InvestmentTransaction( id=uuid.uuid4(), user_id=uid, holding_id=aapl_h.id, type="buy", quantity=Decimal("10"), price=Decimal("228.50"), fees=Decimal("0"), total_amount=Decimal("2285.00"), currency="USD", date=date(2025, 10, 15), created_at=now, ), # AAPL dividend InvestmentTransaction( id=uuid.uuid4(), user_id=uid, holding_id=aapl_h.id, type="dividend", quantity=Decimal("0"), price=Decimal("0"), fees=Decimal("0"), total_amount=Decimal("23.00"), currency="USD", date=date(2026, 2, 15), created_at=now, ), # BTC buy InvestmentTransaction( id=uuid.uuid4(), user_id=uid, holding_id=btc_h.id, type="buy", quantity=Decimal("0.05"), price=Decimal("69500.00"), fees=Decimal("0"), total_amount=Decimal("3475.00"), currency="USD", date=date(2025, 11, 1), created_at=now, ), ] for it in inv_txns: db.add(it) await db.flush() # ── Tax profile & payslips (2025/26 = tax_year 2026) ───────────────── tax_profile = TaxProfile( id=uuid.uuid4(), user_id=uid, tax_year=2026, employer_name_enc=encrypt_field("Demo Corp Ltd"), tax_code="1257L", is_cumulative=True, created_at=now, updated_at=now, ) db.add(tax_profile) await db.flush() # 6 payslips: April – September 2025 for month in range(4, 10): db.add(Payslip( id=uuid.uuid4(), user_id=uid, tax_profile_id=tax_profile.id, period_month=month, period_year=2025, gross_pay=Decimal("4500.00"), income_tax_withheld=Decimal("753.00"), ni_withheld=Decimal("258.00"), net_pay=Decimal("3489.00"), is_p60=False, created_at=now, )) # Manual CGT disposal — employee share scheme, gain exceeds annual exempt db.add(ManualCGTDisposal( id=uuid.uuid4(), user_id=uid, tax_year=2026, disposal_date=date(2025, 9, 15), asset_description_enc=encrypt_field("Tech Corp Ltd — Employee Share Scheme"), proceeds=Decimal("8500.00"), cost_basis=Decimal("4200.00"), created_at=now, )) await db.flush()