05/31 Teller.io connection
This commit is contained in:
@@ -29,6 +29,7 @@ def create_app(config_name=None):
|
||||
from app.routes.ai import ai_bp
|
||||
from app.routes.reports import reports_bp
|
||||
from app.routes.settings import settings_bp
|
||||
from app.routes.teller import teller_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
@@ -41,6 +42,7 @@ def create_app(config_name=None):
|
||||
app.register_blueprint(ai_bp)
|
||||
app.register_blueprint(reports_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(teller_bp)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import (
|
||||
@@ -49,6 +51,7 @@ def create_app(config_name=None):
|
||||
Investment, InvestmentTransaction, NetWorthSnapshot,
|
||||
AiInsight, FxRate
|
||||
)
|
||||
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
||||
|
||||
app.jinja_env.globals['format_currency'] = format_currency
|
||||
app.jinja_env.globals['format_percent'] = format_percent
|
||||
|
||||
@@ -23,6 +23,13 @@ class Config:
|
||||
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
|
||||
|
||||
# Teller
|
||||
TELLER_APP_ID = os.environ.get('TELLER_APP_ID', '')
|
||||
TELLER_ENV = os.environ.get('TELLER_ENV', 'development')
|
||||
TELLER_CERT_PATH = os.environ.get('TELLER_CERT_PATH', '/home/pfm/teller/certificate.pem')
|
||||
TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem')
|
||||
TELLER_WEBHOOK_SECRET = os.environ.get('TELLER_WEBHOOK_SECRET', '')
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
|
||||
@@ -10,3 +10,5 @@ from app.models.investment import Investment, InvestmentTransaction
|
||||
from app.models.net_worth_snapshot import NetWorthSnapshot
|
||||
from app.models.ai_insight import AiInsight
|
||||
from app.models.fx_rate import FxRate
|
||||
|
||||
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TellerEnrollment(db.Model):
|
||||
"""
|
||||
Represents a Teller enrollment — one per connected bank institution.
|
||||
An enrollment contains one or more accounts.
|
||||
"""
|
||||
__tablename__ = 'teller_enrollments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
enrollment_id = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
access_token = db.Column(db.String(128), nullable=False)
|
||||
institution_name = db.Column(db.String(100), nullable=True)
|
||||
user_id = db.Column(db.String(64), nullable=True) # Teller user ID
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_synced_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
accounts = db.relationship('TellerAccount', back_populates='enrollment',
|
||||
cascade='all, delete-orphan', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<TellerEnrollment {self.institution_name} ({self.enrollment_id})>'
|
||||
|
||||
|
||||
class TellerAccount(db.Model):
|
||||
"""
|
||||
Maps a Teller account to a PFM account.
|
||||
Tracks the last synced transaction ID for incremental syncs.
|
||||
"""
|
||||
__tablename__ = 'teller_accounts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
enrollment_id = db.Column(db.Integer, db.ForeignKey('teller_enrollments.id'), nullable=False)
|
||||
teller_account_id = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
pfm_account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True)
|
||||
|
||||
account_name = db.Column(db.String(100), nullable=True)
|
||||
account_type = db.Column(db.String(50), nullable=True) # depository / credit
|
||||
account_subtype = db.Column(db.String(50), nullable=True) # checking / savings / credit_card
|
||||
institution_name = db.Column(db.String(100), nullable=True)
|
||||
last_sync_date = db.Column(db.Date, nullable=True) # date of last successful sync
|
||||
last_teller_txn_id = db.Column(db.String(64), nullable=True) # for from_id pagination
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
enrollment = db.relationship('TellerEnrollment', back_populates='accounts')
|
||||
pfm_account = db.relationship('Account')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<TellerAccount {self.account_name} → PFM #{self.pfm_account_id}>'
|
||||
@@ -0,0 +1,297 @@
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import date
|
||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||
request, jsonify, current_app, session)
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
||||
from app.models.account import Account
|
||||
from app.services.teller_service import (
|
||||
get_accounts, get_balance, sync_preview, import_transactions,
|
||||
ACCOUNT_TYPE_MAP,
|
||||
)
|
||||
|
||||
teller_bp = Blueprint('teller', __name__, url_prefix='/teller')
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from app.extensions import csrf as _csrf
|
||||
# Webhook receives POSTs from Teller servers — no CSRF token
|
||||
_csrf_exempt_views = ['teller.webhook']
|
||||
|
||||
|
||||
# ── Connect callback ──────────────────────────────────────────────────────────
|
||||
|
||||
@teller_bp.route('/callback', methods=['POST'])
|
||||
@login_required
|
||||
def callback():
|
||||
"""
|
||||
Teller Connect posts here after user successfully enrolls.
|
||||
Body: { enrollment: { id, accessToken }, selectedAccount: { ... } }
|
||||
We store the enrollment and discovered accounts, then redirect to mapping.
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data'}), 400
|
||||
|
||||
enrollment_data = data.get('enrollment', {})
|
||||
enrollment_id = enrollment_data.get('id', '')
|
||||
access_token = enrollment_data.get('accessToken', '')
|
||||
|
||||
if not enrollment_id or not access_token:
|
||||
return jsonify({'error': 'Missing enrollment data'}), 400
|
||||
|
||||
# Upsert enrollment
|
||||
enrollment = TellerEnrollment.query.filter_by(enrollment_id=enrollment_id).first()
|
||||
if not enrollment:
|
||||
enrollment = TellerEnrollment(
|
||||
enrollment_id=enrollment_id,
|
||||
access_token=access_token,
|
||||
)
|
||||
db.session.add(enrollment)
|
||||
|
||||
# Fetch accounts from Teller
|
||||
try:
|
||||
teller_accounts = get_accounts(access_token)
|
||||
except Exception as e:
|
||||
log.error(f'[teller] get_accounts failed: {e}')
|
||||
return jsonify({'error': f'Could not fetch accounts: {e}'}), 502
|
||||
|
||||
institution_name = ''
|
||||
for ta in teller_accounts:
|
||||
institution_name = ta.get('institution', {}).get('name', '')
|
||||
ta_id = ta['id']
|
||||
|
||||
existing = TellerAccount.query.filter_by(teller_account_id=ta_id).first()
|
||||
if not existing:
|
||||
subtype = ta.get('subtype', 'other').lower()
|
||||
db.session.add(TellerAccount(
|
||||
enrollment=enrollment,
|
||||
teller_account_id=ta_id,
|
||||
account_name=ta.get('name', ''),
|
||||
account_type=ta.get('type', ''),
|
||||
account_subtype=subtype,
|
||||
institution_name=institution_name,
|
||||
))
|
||||
|
||||
enrollment.institution_name = institution_name
|
||||
enrollment.user_id = enrollment_data.get('user', {}).get('id', '')
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'status': 'ok', 'redirect': url_for('teller.map_accounts', enrollment_id=enrollment_id)})
|
||||
|
||||
|
||||
@teller_bp.route('/map/<enrollment_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def map_accounts(enrollment_id):
|
||||
"""
|
||||
Let user map each Teller account to a PFM account (or create new).
|
||||
POST saves the mapping and redirects to sync preview.
|
||||
"""
|
||||
enrollment = TellerEnrollment.query.filter_by(enrollment_id=enrollment_id).first_or_404()
|
||||
teller_accounts = enrollment.accounts.filter_by(is_active=True).all()
|
||||
pfm_accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
for ta in teller_accounts:
|
||||
key = f'pfm_account_{ta.id}'
|
||||
val = request.form.get(key, '')
|
||||
if val == 'new':
|
||||
# Auto-create a new PFM account
|
||||
subtype = ta.account_subtype or 'checking'
|
||||
pfm_type = ACCOUNT_TYPE_MAP.get(subtype, 'other')
|
||||
new_acct = Account(
|
||||
name=f'{ta.institution_name} — {ta.account_name}',
|
||||
account_type=pfm_type,
|
||||
color='#4F81C7',
|
||||
icon='bi-bank',
|
||||
balance=0,
|
||||
)
|
||||
db.session.add(new_acct)
|
||||
db.session.flush()
|
||||
ta.pfm_account_id = new_acct.id
|
||||
elif val.isdigit():
|
||||
ta.pfm_account_id = int(val)
|
||||
# val == '' means skip this account
|
||||
db.session.commit()
|
||||
|
||||
# Redirect to sync all mapped accounts
|
||||
mapped = [ta for ta in teller_accounts if ta.pfm_account_id]
|
||||
if not mapped:
|
||||
flash('No accounts mapped. Select at least one account to sync.', 'warning')
|
||||
return redirect(url_for('teller.map_accounts', enrollment_id=enrollment_id))
|
||||
|
||||
flash(f'{len(mapped)} account(s) mapped. Ready to sync.', 'success')
|
||||
return redirect(url_for('teller.index'))
|
||||
|
||||
return render_template('teller/map_accounts.html',
|
||||
enrollment=enrollment,
|
||||
teller_accounts=teller_accounts,
|
||||
pfm_accounts=pfm_accounts)
|
||||
|
||||
|
||||
# ── Index — enrolled accounts overview ───────────────────────────────────────
|
||||
|
||||
@teller_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
enrollments = TellerEnrollment.query.filter_by(is_active=True).all()
|
||||
return render_template('teller/index.html', enrollments=enrollments)
|
||||
|
||||
|
||||
# ── Sync: preview then confirm ────────────────────────────────────────────────
|
||||
|
||||
@teller_bp.route('/sync/<int:teller_account_id>', methods=['GET'])
|
||||
@login_required
|
||||
def sync_preview_view(teller_account_id):
|
||||
"""Fetch transactions from Teller and show preview before importing."""
|
||||
ta = db.get_or_404(TellerAccount, teller_account_id)
|
||||
|
||||
if not ta.pfm_account_id:
|
||||
flash('This account is not mapped to a PFM account. Please map it first.', 'warning')
|
||||
return redirect(url_for('teller.map_accounts', enrollment_id=ta.enrollment.enrollment_id))
|
||||
|
||||
try:
|
||||
preview = sync_preview(ta)
|
||||
except Exception as e:
|
||||
flash(f'Sync failed: {e}', 'danger')
|
||||
return redirect(url_for('teller.index'))
|
||||
|
||||
# Store preview in session for confirm step
|
||||
session['teller_preview'] = [
|
||||
{
|
||||
'teller_id': p['teller_id'],
|
||||
'date': p['date'].isoformat(),
|
||||
'transaction_type': p['transaction_type'],
|
||||
'amount': p['amount'],
|
||||
'description': p['description'],
|
||||
'account_id': p['account_id'],
|
||||
'category_id': p['category_id'],
|
||||
'notes': p['notes'],
|
||||
}
|
||||
for p in preview
|
||||
]
|
||||
session['teller_account_id'] = teller_account_id
|
||||
|
||||
return render_template('teller/preview.html',
|
||||
ta=ta,
|
||||
preview=preview,
|
||||
count=len(preview))
|
||||
|
||||
|
||||
@teller_bp.route('/sync/confirm', methods=['POST'])
|
||||
@login_required
|
||||
def sync_confirm():
|
||||
"""Import the previewed transactions."""
|
||||
raw = session.pop('teller_preview', [])
|
||||
ta_id = session.pop('teller_account_id', None)
|
||||
|
||||
if not raw or not ta_id:
|
||||
flash('No pending import. Please sync again.', 'warning')
|
||||
return redirect(url_for('teller.index'))
|
||||
|
||||
ta = db.get_or_404(TellerAccount, ta_id)
|
||||
|
||||
# Reconstruct parsed list with date objects
|
||||
from datetime import date as date_cls
|
||||
parsed = []
|
||||
for r in raw:
|
||||
r['date'] = date_cls.fromisoformat(r['date'])
|
||||
parsed.append(r)
|
||||
|
||||
imported, skipped = import_transactions(parsed, ta)
|
||||
flash(f'Imported {imported} transaction(s). Skipped {skipped} duplicate(s).', 'success')
|
||||
return redirect(url_for('transactions.index'))
|
||||
|
||||
|
||||
@teller_bp.route('/sync/all', methods=['POST'])
|
||||
@login_required
|
||||
def sync_all():
|
||||
"""Sync all mapped accounts — redirects to first account's preview."""
|
||||
enrollments = TellerEnrollment.query.filter_by(is_active=True).all()
|
||||
mapped = []
|
||||
for e in enrollments:
|
||||
for ta in e.accounts.filter_by(is_active=True).all():
|
||||
if ta.pfm_account_id:
|
||||
mapped.append(ta)
|
||||
|
||||
if not mapped:
|
||||
flash('No accounts mapped for sync.', 'warning')
|
||||
return redirect(url_for('teller.index'))
|
||||
|
||||
# For simplicity, sync first account; user can chain through others
|
||||
return redirect(url_for('teller.sync_preview_view', teller_account_id=mapped[0].id))
|
||||
|
||||
|
||||
# ── Balance refresh ───────────────────────────────────────────────────────────
|
||||
|
||||
@teller_bp.route('/balance/<int:teller_account_id>', methods=['POST'])
|
||||
@login_required
|
||||
def refresh_balance(teller_account_id):
|
||||
"""Fetch live balance from Teller and update the linked PFM account."""
|
||||
ta = db.get_or_404(TellerAccount, teller_account_id)
|
||||
if not ta.pfm_account_id:
|
||||
return jsonify({'error': 'Account not mapped'}), 400
|
||||
|
||||
try:
|
||||
bal_data = get_balance(ta.enrollment.access_token, ta.teller_account_id)
|
||||
available = float(bal_data.get('available') or bal_data.get('ledger') or 0)
|
||||
ta.pfm_account.balance = available
|
||||
db.session.commit()
|
||||
return jsonify({'balance': available, 'status': 'ok'})
|
||||
except Exception as e:
|
||||
log.error(f'[teller] balance refresh failed: {e}')
|
||||
return jsonify({'error': str(e)}), 502
|
||||
|
||||
|
||||
# ── Disconnect ────────────────────────────────────────────────────────────────
|
||||
|
||||
@teller_bp.route('/disconnect/<int:enrollment_db_id>', methods=['POST'])
|
||||
@login_required
|
||||
def disconnect(enrollment_db_id):
|
||||
enrollment = db.get_or_404(TellerEnrollment, enrollment_db_id)
|
||||
enrollment.is_active = False
|
||||
for ta in enrollment.accounts:
|
||||
ta.is_active = False
|
||||
db.session.commit()
|
||||
flash(f'Disconnected from {enrollment.institution_name}.', 'info')
|
||||
return redirect(url_for('teller.index'))
|
||||
|
||||
|
||||
# ── Webhook: transactions.processed ──────────────────────────────────────────
|
||||
|
||||
@teller_bp.route('/webhook', methods=['POST'])
|
||||
@_csrf.exempt
|
||||
def webhook():
|
||||
"""
|
||||
Teller fires this when new transactions are available.
|
||||
Verifies signature then marks account as needing sync.
|
||||
Does NOT auto-import — user must confirm via the UI.
|
||||
"""
|
||||
# Verify Teller webhook signature
|
||||
signing_secret = current_app.config.get('TELLER_WEBHOOK_SECRET', '')
|
||||
if signing_secret:
|
||||
sig_header = request.headers.get('Teller-Signature', '')
|
||||
body = request.get_data()
|
||||
expected = hmac.new(signing_secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(f'sha256={expected}', sig_header):
|
||||
log.warning('[teller] webhook signature mismatch')
|
||||
return jsonify({'error': 'Invalid signature'}), 401
|
||||
|
||||
payload = request.get_json()
|
||||
if not payload:
|
||||
return jsonify({'error': 'No payload'}), 400
|
||||
|
||||
event_type = payload.get('type', '')
|
||||
log.info(f'[teller] webhook received: {event_type}')
|
||||
|
||||
if event_type == 'transactions.processed':
|
||||
enrollment_id = payload.get('enrollment_id', '')
|
||||
enrollment = TellerEnrollment.query.filter_by(enrollment_id=enrollment_id).first()
|
||||
if enrollment:
|
||||
# Just log — user syncs manually via UI preview flow
|
||||
log.info(f'[teller] new transactions available for enrollment {enrollment_id}')
|
||||
|
||||
return jsonify({'status': 'received'}), 200
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Teller Service — connects to Teller API using mTLS + HTTP Basic Auth.
|
||||
|
||||
Authentication:
|
||||
- mTLS: client cert + key (downloaded from Teller Dashboard)
|
||||
- HTTP Basic Auth: access_token as username, empty password
|
||||
|
||||
Endpoints used:
|
||||
GET /accounts → list enrolled accounts
|
||||
GET /accounts/:id/balances → account balance
|
||||
GET /accounts/:id/transactions → transaction list (with date range)
|
||||
|
||||
Environment: development (real banks, reviewed by Teller)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import requests
|
||||
from datetime import date, timedelta
|
||||
from flask import current_app
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TELLER_BASE = 'https://api.teller.io'
|
||||
TELLER_VERSION = '2020-10-12'
|
||||
|
||||
# Teller category → PFM category name mapping
|
||||
CATEGORY_MAP = {
|
||||
'accommodation': 'Housing',
|
||||
'advertising': 'Other',
|
||||
'bar': 'Food & Dining',
|
||||
'charity': 'Gifts',
|
||||
'clothing': 'Shopping',
|
||||
'dining': 'Food & Dining',
|
||||
'education': 'Education',
|
||||
'electronics': 'Shopping',
|
||||
'entertainment': 'Entertainment',
|
||||
'fuel': 'Transport',
|
||||
'general': 'Other',
|
||||
'groceries': 'Food & Dining',
|
||||
'health': 'Health',
|
||||
'home': 'Housing',
|
||||
'income': 'Other Income',
|
||||
'insurance': 'Insurance',
|
||||
'investment': 'Investment',
|
||||
'loan': 'Other',
|
||||
'office': 'Other',
|
||||
'phone': 'Utilities',
|
||||
'service': 'Other',
|
||||
'shopping': 'Shopping',
|
||||
'software': 'Subscriptions',
|
||||
'sport': 'Health',
|
||||
'tax': 'Other',
|
||||
'transport': 'Transport',
|
||||
'transportation': 'Transport',
|
||||
'utilities': 'Utilities',
|
||||
}
|
||||
|
||||
# Teller account subtype → PFM account type
|
||||
ACCOUNT_TYPE_MAP = {
|
||||
'checking': 'checking',
|
||||
'savings': 'savings',
|
||||
'credit_card': 'credit_card',
|
||||
'money_market':'savings',
|
||||
'cd': 'savings',
|
||||
'brokerage': 'investment',
|
||||
'ira': 'investment',
|
||||
'401k': 'investment',
|
||||
'other': 'other',
|
||||
}
|
||||
|
||||
|
||||
def _session(access_token):
|
||||
"""
|
||||
Build a requests.Session with mTLS client certificate and HTTP Basic Auth.
|
||||
Cert/key paths come from app config (TELLER_CERT_PATH / TELLER_KEY_PATH).
|
||||
"""
|
||||
cert_path = current_app.config.get('TELLER_CERT_PATH', '')
|
||||
key_path = current_app.config.get('TELLER_KEY_PATH', '')
|
||||
|
||||
if not cert_path or not key_path:
|
||||
raise ValueError('TELLER_CERT_PATH and TELLER_KEY_PATH must be set in .env')
|
||||
if not os.path.exists(cert_path):
|
||||
raise FileNotFoundError(f'Teller cert not found: {cert_path}')
|
||||
if not os.path.exists(key_path):
|
||||
raise FileNotFoundError(f'Teller key not found: {key_path}')
|
||||
|
||||
session = requests.Session()
|
||||
session.cert = (cert_path, key_path)
|
||||
session.auth = (access_token, '')
|
||||
session.headers.update({
|
||||
'Teller-Version': TELLER_VERSION,
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
return session
|
||||
|
||||
|
||||
def get_accounts(access_token):
|
||||
"""
|
||||
Fetch all accounts for an enrollment.
|
||||
Returns list of account dicts or raises on error.
|
||||
"""
|
||||
session = _session(access_token)
|
||||
resp = session.get(f'{TELLER_BASE}/accounts', timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def get_balance(access_token, account_id):
|
||||
"""Fetch live balance for a single account."""
|
||||
session = _session(access_token)
|
||||
resp = session.get(f'{TELLER_BASE}/accounts/{account_id}/balances', timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def get_transactions(access_token, account_id, start_date=None, end_date=None,
|
||||
from_id=None, count=None):
|
||||
"""
|
||||
Fetch transactions for an account.
|
||||
Uses date range for initial sync, from_id for incremental.
|
||||
Returns list of transaction dicts.
|
||||
"""
|
||||
session = _session(access_token)
|
||||
params = {}
|
||||
if start_date:
|
||||
params['start_date'] = start_date.isoformat() if hasattr(start_date, 'isoformat') else start_date
|
||||
if end_date:
|
||||
params['end_date'] = end_date.isoformat() if hasattr(end_date, 'isoformat') else end_date
|
||||
if from_id:
|
||||
params['from_id'] = from_id
|
||||
if count:
|
||||
params['count'] = count
|
||||
|
||||
resp = session.get(
|
||||
f'{TELLER_BASE}/accounts/{account_id}/transactions',
|
||||
params=params,
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def parse_transaction(teller_txn, pfm_account_id, category_id_map):
|
||||
"""
|
||||
Convert a Teller transaction dict to a PFM transaction dict ready for import.
|
||||
Returns dict with keys matching Transaction model fields.
|
||||
|
||||
Teller amounts:
|
||||
- Positive = money leaving the account (expense / debit)
|
||||
- Negative = money entering the account (income / credit)
|
||||
"""
|
||||
amount_raw = float(teller_txn.get('amount', 0))
|
||||
# Teller: positive = outflow (expense), negative = inflow (income)
|
||||
if amount_raw > 0:
|
||||
txn_type = 'expense'
|
||||
amount = amount_raw
|
||||
else:
|
||||
txn_type = 'income'
|
||||
amount = abs(amount_raw)
|
||||
|
||||
description = teller_txn.get('description', '').strip() or 'Teller transaction'
|
||||
# Use enriched counterparty name if available
|
||||
details = teller_txn.get('details', {}) or {}
|
||||
counterparty = (details.get('counterparty') or {}).get('name', '')
|
||||
if counterparty and counterparty.upper() != description.upper():
|
||||
description = counterparty
|
||||
|
||||
# Map Teller category to PFM category
|
||||
teller_cat = (details.get('category') or '').lower()
|
||||
pfm_cat_name = CATEGORY_MAP.get(teller_cat, 'Other')
|
||||
category_id = category_id_map.get(pfm_cat_name)
|
||||
|
||||
return {
|
||||
'teller_id': teller_txn['id'],
|
||||
'date': date.fromisoformat(teller_txn['date']),
|
||||
'transaction_type': txn_type,
|
||||
'amount': amount,
|
||||
'description': description,
|
||||
'account_id': pfm_account_id,
|
||||
'category_id': category_id,
|
||||
'notes': f'Teller: {teller_txn.get("type", "")}',
|
||||
'status': teller_txn.get('status', 'posted'),
|
||||
}
|
||||
|
||||
|
||||
def build_category_map():
|
||||
"""Build {pfm_category_name: category_id} from DB."""
|
||||
from app.models.category import Category
|
||||
cats = Category.query.filter_by(is_active=True).all()
|
||||
return {c.name: c.id for c in cats}
|
||||
|
||||
|
||||
def sync_preview(teller_account, days_back=90):
|
||||
"""
|
||||
Fetch transactions for a TellerAccount and return a preview list.
|
||||
Does NOT write to DB — just returns parsed dicts for display.
|
||||
|
||||
For incremental sync: uses last_sync_date - 7 days as start_date.
|
||||
For first sync: goes back `days_back` days.
|
||||
"""
|
||||
from app.models.teller_enrollment import TellerEnrollment
|
||||
enrollment = teller_account.enrollment
|
||||
|
||||
today = date.today()
|
||||
if teller_account.last_sync_date:
|
||||
# Incremental: overlap 7 days to catch pending→posted changes
|
||||
start = teller_account.last_sync_date - timedelta(days=7)
|
||||
else:
|
||||
# First sync
|
||||
start = today - timedelta(days=days_back)
|
||||
|
||||
try:
|
||||
raw_txns = get_transactions(
|
||||
enrollment.access_token,
|
||||
teller_account.teller_account_id,
|
||||
start_date=start,
|
||||
end_date=today,
|
||||
)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
log.error(f'[teller] fetch failed for {teller_account.teller_account_id}: {e}')
|
||||
raise
|
||||
|
||||
cat_map = build_category_map()
|
||||
parsed = []
|
||||
for txn in raw_txns:
|
||||
p = parse_transaction(txn, teller_account.pfm_account_id, cat_map)
|
||||
parsed.append(p)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def import_transactions(parsed_txns, teller_account):
|
||||
"""
|
||||
Import a list of already-parsed transaction dicts into PFM.
|
||||
Skips duplicates based on teller_id stored in notes field.
|
||||
Returns (imported_count, skipped_count).
|
||||
"""
|
||||
from app.extensions import db
|
||||
from app.models.transaction import Transaction
|
||||
from app.services.account_service import calc_balance
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
affected_accounts = set()
|
||||
|
||||
for p in parsed_txns:
|
||||
# Duplicate check: match on teller_id in notes OR date+amount+description
|
||||
teller_id = p['teller_id']
|
||||
existing = Transaction.query.filter(
|
||||
Transaction.notes.like(f'%{teller_id}%')
|
||||
).first()
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
txn = Transaction(
|
||||
account_id=p['account_id'],
|
||||
category_id=p.get('category_id'),
|
||||
transaction_type=p['transaction_type'],
|
||||
amount=p['amount'],
|
||||
description=p['description'],
|
||||
date=p['date'],
|
||||
notes=f"Teller:{teller_id}",
|
||||
)
|
||||
db.session.add(txn)
|
||||
if p['account_id']:
|
||||
affected_accounts.add(p['account_id'])
|
||||
imported += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Update sync metadata
|
||||
from datetime import datetime
|
||||
teller_account.last_sync_date = date.today()
|
||||
if parsed_txns:
|
||||
teller_account.last_teller_txn_id = parsed_txns[0]['teller_id']
|
||||
from app.extensions import db as _db
|
||||
_db.session.commit()
|
||||
|
||||
for account_id in affected_accounts:
|
||||
calc_balance(account_id)
|
||||
|
||||
return imported, skipped
|
||||
@@ -6,6 +6,15 @@
|
||||
<div class="row g-3">
|
||||
<!-- Nav cards -->
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<a href="{{ url_for('teller.index') }}" class="text-decoration-none">
|
||||
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#10b981'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<i class="bi bi-bank2" style="font-size:2rem;color:#10b981;"></i>
|
||||
<div style="font-size:14px;font-weight:600;margin-top:10px;">Bank Connections</div>
|
||||
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Connect US bank accounts via Teller</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<a href="{{ url_for('settings.profile') }}" class="text-decoration-none">
|
||||
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#3b82f6'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<i class="bi bi-person-circle" style="font-size:2rem;color:#3b82f6;"></i>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Bank Connections{% endblock %}
|
||||
{% block page_title %}Bank Connections{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<button id="tellerConnectBtn" class="btn btn-sm btn-primary" style="font-size:12px;">
|
||||
<i class="bi bi-bank me-1"></i>Connect a Bank
|
||||
</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if enrollments %}
|
||||
<div class="row g-3 mb-4">
|
||||
{% for enrollment in enrollments %}
|
||||
{% set accounts = enrollment.accounts.filter_by(is_active=True).all() %}
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="pcard">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<div style="font-size:15px;font-weight:600;">{{ enrollment.institution_name or 'Unknown Bank' }}</div>
|
||||
<div style="font-size:12px;color:var(--muted);">
|
||||
Connected {{ enrollment.created_at.strftime('%b %d, %Y') }} ·
|
||||
Last synced: {{ enrollment.last_synced_at.strftime('%b %d, %H:%M') if enrollment.last_synced_at else 'Never' }}
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('teller.disconnect', enrollment_db_id=enrollment.id) }}"
|
||||
onsubmit="return confirm('Disconnect from {{ enrollment.institution_name }}? Existing transactions are kept.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:11px;">Disconnect</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% for ta in accounts %}
|
||||
<div class="d-flex justify-content-between align-items-center py-2" style="border-top:1px solid var(--border);">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div style="width:32px;height:32px;border-radius:8px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:14px;">
|
||||
<i class="bi {% if ta.account_subtype == 'credit_card' %}bi-credit-card{% elif ta.account_subtype == 'savings' %}bi-piggy-bank{% else %}bi-bank{% endif %}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:500;">{{ ta.account_name }}</div>
|
||||
<div style="font-size:11px;color:var(--muted);">
|
||||
{{ ta.account_subtype | replace('_',' ') | title }}
|
||||
{% if ta.pfm_account %}
|
||||
· Mapped to <strong>{{ ta.pfm_account.name }}</strong>
|
||||
{% else %}
|
||||
· <span style="color:#f59e0b;">Not mapped</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if ta.pfm_account %}
|
||||
<!-- Balance refresh -->
|
||||
<button onclick="refreshBalance({{ ta.id }}, this)"
|
||||
class="btn btn-sm btn-outline-secondary" style="font-size:11px;" title="Refresh live balance">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
<!-- Sync transactions -->
|
||||
<a href="{{ url_for('teller.sync_preview_view', teller_account_id=ta.id) }}"
|
||||
class="btn btn-sm btn-outline-primary" style="font-size:11px;">
|
||||
<i class="bi bi-cloud-download me-1"></i>Sync
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('teller.map_accounts', enrollment_id=enrollment.enrollment_id) }}"
|
||||
class="btn btn-sm btn-outline-warning" style="font-size:11px;">Map Account</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Sync all -->
|
||||
{% if accounts | selectattr('pfm_account_id') | list %}
|
||||
<div class="mt-3 text-end">
|
||||
<form method="POST" action="{{ url_for('teller.sync_all') }}" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;">
|
||||
<i class="bi bi-cloud-download me-1"></i>Sync All Accounts
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="pcard text-center py-5 col-12 col-lg-6 mx-auto">
|
||||
<i class="bi bi-bank2 text-muted" style="font-size:3rem;"></i>
|
||||
<h5 class="mt-3 mb-1">No banks connected</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
Connect your US bank accounts to automatically sync transactions and balances.
|
||||
</p>
|
||||
<button id="tellerConnectBtn2" class="btn btn-primary">
|
||||
<i class="bi bi-bank me-1"></i>Connect a Bank
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Setup instructions -->
|
||||
<div class="pcard mt-2" style="background:#f8fafc;">
|
||||
<div class="pcard-title mb-2">Setup Requirements</div>
|
||||
<div style="font-size:13px;color:var(--muted);line-height:1.8;">
|
||||
<div><i class="bi bi-check-circle-fill text-success me-2"></i>Teller App ID set in <code>.env</code></div>
|
||||
<div><i class="bi bi-check-circle-fill text-success me-2"></i>Client certificate at <code>{{ config.TELLER_CERT_PATH }}</code></div>
|
||||
<div><i class="bi bi-check-circle-fill text-success me-2"></i>Private key at <code>{{ config.TELLER_KEY_PATH }}</code></div>
|
||||
<div><i class="bi bi-info-circle text-muted me-2"></i>Environment: <strong>{{ config.TELLER_ENV }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.teller.io/connect/connect.js"></script>
|
||||
<script>
|
||||
const CSRF = '{{ csrf_token() }}';
|
||||
|
||||
function initTellerConnect(btn) {
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function() {
|
||||
const teller = TellerConnect.setup({
|
||||
applicationId: '{{ config.TELLER_APP_ID }}',
|
||||
environment: '{{ config.TELLER_ENV }}',
|
||||
products: ['transactions'],
|
||||
onSuccess: function(enrollment) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>Connecting…';
|
||||
fetch('/teller/callback', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': CSRF,
|
||||
},
|
||||
body: JSON.stringify({ enrollment: enrollment }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
alert('Connection error: ' + (data.error || 'Unknown'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-bank me-1"></i>Connect a Bank';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
alert('Network error. Please try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-bank me-1"></i>Connect a Bank';
|
||||
});
|
||||
},
|
||||
onExit: function() {
|
||||
console.log('Teller Connect closed');
|
||||
},
|
||||
onFailure: function(error) {
|
||||
alert('Connection failed: ' + error.message);
|
||||
},
|
||||
});
|
||||
teller.open();
|
||||
});
|
||||
}
|
||||
|
||||
initTellerConnect(document.getElementById('tellerConnectBtn'));
|
||||
initTellerConnect(document.getElementById('tellerConnectBtn2'));
|
||||
|
||||
function refreshBalance(taId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
||||
fetch('/teller/balance/' + taId, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': CSRF },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
||||
if (data.balance != null) {
|
||||
const sym = '{{ current_user.currency_symbol }}';
|
||||
btn.title = 'Balance: ' + sym + parseFloat(data.balance).toLocaleString(undefined, {minimumFractionDigits:2});
|
||||
btn.style.color = '#10b981';
|
||||
setTimeout(() => btn.style.color = '', 3000);
|
||||
} else {
|
||||
btn.title = data.error || 'Failed';
|
||||
btn.style.color = '#ef4444';
|
||||
setTimeout(() => btn.style.color = '', 3000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Map Accounts{% endblock %}
|
||||
{% block page_title %}Map Bank Accounts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-7">
|
||||
|
||||
<div class="pcard mb-3" style="background:#f0fdf4;border-color:#bbf7d0;">
|
||||
<div style="font-size:13px;color:#166534;">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
<strong>{{ enrollment.institution_name }}</strong> connected successfully.
|
||||
Map each account below to a PFM account, or create a new one automatically.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pcard">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{% for ta in teller_accounts %}
|
||||
<div class="mb-4 pb-4" style="{% if not loop.last %}border-bottom:1px solid var(--border);{% endif %}">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div style="width:36px;height:36px;border-radius:9px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:16px;">
|
||||
<i class="bi {% if ta.account_subtype == 'credit_card' %}bi-credit-card{% elif ta.account_subtype == 'savings' %}bi-piggy-bank{% else %}bi-bank{% endif %}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:600;">{{ ta.account_name }}</div>
|
||||
<div style="font-size:11px;color:var(--muted);">
|
||||
{{ ta.institution_name }} · {{ ta.account_subtype | replace('_',' ') | title }}
|
||||
· ID: <span class="mono">{{ ta.teller_account_id[-8:] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-label" style="font-size:13px;font-weight:500;">Map to PFM Account</label>
|
||||
<select name="pfm_account_{{ ta.id }}" class="form-select form-select-sm">
|
||||
<option value="">— Skip this account —</option>
|
||||
<option value="new" {% if not pfm_accounts %}selected{% endif %}>
|
||||
✨ Create new account automatically
|
||||
</option>
|
||||
{% for acct in pfm_accounts %}
|
||||
<option value="{{ acct.id }}" {% if ta.pfm_account_id == acct.id %}selected{% endif %}>
|
||||
{{ acct.name }} ({{ acct.account_type | replace('_',' ') | title }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="text-muted" style="font-size:11px;">
|
||||
"Create new" auto-names the account "{{ ta.institution_name }} — {{ ta.account_name }}"
|
||||
</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="submit" class="btn btn-primary">Save Mapping & Continue</button>
|
||||
<a href="{{ url_for('teller.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sync Preview{% endblock %}
|
||||
{% block page_title %}Sync Preview — {{ ta.account_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pcard mb-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:600;">{{ ta.institution_name }} — {{ ta.account_name }}</div>
|
||||
<div style="font-size:12px;color:var(--muted);">
|
||||
Mapped to: <strong>{{ ta.pfm_account.name }}</strong> ·
|
||||
{{ count }} transaction(s) found
|
||||
{% if ta.last_sync_date %}· Last sync: {{ ta.last_sync_date.strftime('%b %d, %Y') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('teller.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">Cancel</a>
|
||||
{% if count > 0 %}
|
||||
<form method="POST" action="{{ url_for('teller.sync_confirm') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-success" style="font-size:12px;">
|
||||
<i class="bi bi-check-lg me-1"></i>Import {{ count }} Transaction(s)
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if preview %}
|
||||
<div class="pcard p-0">
|
||||
<table class="pfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding-left:20px;">Date</th>
|
||||
<th>Description</th>
|
||||
<th>Type</th>
|
||||
<th>Category</th>
|
||||
<th class="text-end" style="padding-right:20px;">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for txn in preview %}
|
||||
<tr>
|
||||
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">
|
||||
{{ txn.date.strftime('%b %d, %Y') }}
|
||||
</td>
|
||||
<td style="font-size:13px;">{{ txn.description }}</td>
|
||||
<td>
|
||||
<span class="badge {% if txn.transaction_type == 'income' %}badge-income{% else %}badge-expense{% endif %}" style="font-size:11px;">
|
||||
{{ txn.transaction_type | title }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="font-size:12px;color:var(--muted);">
|
||||
{% if txn.category_id %}
|
||||
{% for cat in [txn.category_id] %}
|
||||
{# Look up category name via the category_id #}
|
||||
{{ txn.notes | replace('Teller:', '') }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span style="color:#f59e0b;">Uncategorised</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end mono {% if txn.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}"
|
||||
style="font-size:13px;font-weight:600;padding-right:20px;">
|
||||
{% if txn.transaction_type == 'income' %}+{% else %}-{% endif %}{{ txn.amount | currency }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<form method="POST" action="{{ url_for('teller.sync_confirm') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>Confirm Import ({{ count }} transactions)
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="pcard text-center py-5">
|
||||
<i class="bi bi-check-circle text-success" style="font-size:3rem;"></i>
|
||||
<h5 class="mt-3 mb-1">All up to date</h5>
|
||||
<p class="text-muted small">No new transactions found since last sync.</p>
|
||||
<a href="{{ url_for('teller.index') }}" class="btn btn-sm btn-outline-secondary">Back</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user