first
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
env/
|
||||
__pycache__/
|
||||
dist/
|
||||
*.egg-info/
|
||||
7
LICENSE.txt
Normal file
7
LICENSE.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright 2022 Zev Averbach
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
102
README.md
Normal file
102
README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# AVT Fresh!
|
||||
|
||||
This is a wrapper of [the roly-poly, not 100% ergonomic Freshbooks web API](https://www.freshbooks.com/api/start). It is far from comprehensive: It was created for the specific client- and invoice-related needs of [Averbach Transcription](https://avtranscription.com).
|
||||
|
||||
There are "band-aids" here to work around some of the API's shortcomings.
|
||||
|
||||
# The Goodies
|
||||
|
||||
## Invoices
|
||||
`get`, `get_one`, `create`, `send`, `update` and `delete` are the bread and butter functions here.
|
||||
|
||||
The `get...` functions return some handy `NamedTuple` instances with helpful attributes, notably `FreshbooksInvoice.lines` which have `Decimal` values where you would hope to find them. Also some lookups for addressing the `FreshbooksLine`s you may be interested in.
|
||||
|
||||
Then you have helpers `get_all_draft_invoices`, `get_all_invoices_for_org_name`, `get_all_invoices_for_client_id`, and `get_draft_invoices_for_client_id`.
|
||||
|
||||
### Create an Invoice
|
||||
The signature of `invoice.create` is like so:
|
||||
|
||||
```python
|
||||
def create(
|
||||
client_id: int,
|
||||
notes: str,
|
||||
lines: list[dict],
|
||||
status: str | int,
|
||||
contacts: list[dict] | None = None,
|
||||
po_number=None,
|
||||
create_date=None,
|
||||
) -> dict:
|
||||
```
|
||||
#### `lines`
|
||||
The dictionaries must contain entries for `name`, `description`, `unit_cost`, and `qty`.
|
||||
The values must be JSON-serializable, so no `Decimal`s for example (all strings is fine).
|
||||
|
||||
#### `contacts`
|
||||
Each of these dictionaries should simply be `{'contactid': <contactid>}`.
|
||||
|
||||
#### `status`
|
||||
Status can be any of the `v3_status` values as a `str` or `1` or `4` (draft/paid).
|
||||
|
||||
|
||||
## Clients
|
||||
`get_all_clients`, `get_one`, `create`, and `delete` are available here.
|
||||
|
||||
Once more the `get...` functions return `NamedTuple` instances with some helpful attributes, notably `FreshbooksClient.contacts` and a couple of related lookups (`.contact_id_email_lookup` and `.email_contact_id_lookup`).
|
||||
|
||||
Then, `update_contacts`, `delete_contact`, `add_contacts`, `get_freshbooks_client_from_client_id`, `get_freshbooks_client_from_email`, and `get_freshbooks_client_from_org_name`.
|
||||
|
||||
# Install
|
||||
|
||||
```
|
||||
pip install avt-fresh
|
||||
```
|
||||
|
||||
# Prerequisites/Configuration
|
||||
Make yourself a nice little `.env` file in your home directory or wherever you're going to be calling this library's code. It needs to contain:
|
||||
|
||||
```bash
|
||||
FRESHBOOKS_API_CLIENT_ID='blah'
|
||||
FRESHBOOKS_API_CLIENT_SECRET='blah'
|
||||
FRESHBOOKS_REDIRECT_URI="https://blah.com/blah"
|
||||
FRESHBOOKS_ACCOUNT_ID="blah"
|
||||
```
|
||||
|
||||
You can get and set these goodies [here](https://my.freshbooks.com/#/developer). Well, all of them except `FRESHBOOKS_ACCOUNT_ID`, which you can see (there's got to be another way??) by clicking on one of your invoices and grabbing the substring here: `https://my.freshbooks.com/#/invoice/<THIS THING>-1234567`.
|
||||
|
||||
Don't tell anyone but `FRESHBOOKS_REDIRECT_URI` can be pretty much anything! See `Initializing` below 👇.
|
||||
|
||||
## Security
|
||||
Which brings me to an important point. Currently it's going to save your OAuth tokens in the ever-so-insecure path of `~/freshbooks_oauth_token.json`. TODO: don't do this anymore. ¯\_(ツ)_/¯
|
||||
|
||||
# Initializing
|
||||
When you first call one of the functions which touches the Freshbooks API, you'll be prompted in the terminal like so:
|
||||
|
||||
```
|
||||
Please go here and get an auth code: https://my.freshbooks.com/#/developer, then enter it here:
|
||||
```
|
||||
|
||||
If you don't have an app there, create a really basic one. Name and description can be whatever, and you can skip the URL fields.
|
||||
|
||||
Application Type: "Private App"
|
||||
Scopes: `admin:all:legacy`
|
||||
|
||||
Add a redirect URI, it can actually be pretty much anything.
|
||||
|
||||
Finally, once you have an app on that developer page, click into it and click "go to authentication" page. Freshbooks will pop open a tab and go to your redirect URI, appending `?code=blahblahblah` to it. Grab the "blah blah blah" value and paste it into the prompt.
|
||||
|
||||
You should only have to do this once in each environment you use this library in.
|
||||
|
||||
# Hardcoded Stuff / TODOs
|
||||
Here are some quirks and TODOs. PRs are welcome!:
|
||||
|
||||
Currently all invoices will have Stripe added as a payment option. There's no option to skip this, and there's no other payment methods available in this library.
|
||||
|
||||
Also, only Python 3.10 is supported at the moment.
|
||||
|
||||
Then, when it comes to invoice statuses, we're only using `v3_status` strings, not the numbers. What's more, when you create an invoice we're only supporting two possible statuses: "draft" and "paid".
|
||||
|
||||
The `create`, `update`, and `delete` functions return dictionaries rather than an instance of the appropriate `NamedTuple`. This would be a great improvement!
|
||||
|
||||
The docs need improvement for sure: For now, have a peek at the source code, which includes pretty comprehensive type hints at the very least.
|
||||
|
||||
There are no tests! Is the other thing. Although this code has been used in production in at least one company with some success.
|
||||
171
avt_fresh/__init__.py
Normal file
171
avt_fresh/__init__.py
Normal file
@@ -0,0 +1,171 @@
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv, find_dotenv
|
||||
import requests
|
||||
|
||||
from avt_fresh.token import Token, NoToken
|
||||
|
||||
dotenv_path = find_dotenv()
|
||||
if not dotenv_path:
|
||||
dotenv_path = os.getcwd() + "/.env"
|
||||
load_dotenv(dotenv_path)
|
||||
|
||||
BASE_URL = "https://api.freshbooks.com"
|
||||
URL = f"{BASE_URL}/auth/oauth/token"
|
||||
HEADERS = {"Content-Type": "application/json"}
|
||||
|
||||
CLIENT_ID = os.getenv("FRESHBOOKS_API_CLIENT_ID")
|
||||
SECRET = os.getenv("FRESHBOOKS_API_CLIENT_SECRET")
|
||||
ACCOUNT_ID = os.getenv("FRESHBOOKS_ACCOUNT_ID")
|
||||
REDIRECT_URI = os.getenv("FRESHBOOKS_REDIRECT_URI")
|
||||
|
||||
|
||||
class ReRun(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AvtFreshException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def make_headers():
|
||||
return {**HEADERS, "Authorization": f"Bearer {_get_access_token()}"}
|
||||
|
||||
|
||||
REQUEST_LOOKUP = {
|
||||
"GET": (requests.get, "params"),
|
||||
"PUT": (requests.put, "json"),
|
||||
"POST": (requests.post, "json"),
|
||||
}
|
||||
|
||||
|
||||
def REQUEST(url, method_name: str, endpoint: str, stuff=None):
|
||||
method, arg_name = REQUEST_LOOKUP[method_name]
|
||||
if endpoint == "" or endpoint.startswith("?"):
|
||||
rendered_url = f"{url}{endpoint}"
|
||||
else:
|
||||
rendered_url = f"{url}/{endpoint}"
|
||||
_, the_rest = rendered_url.split("https://")
|
||||
if "//" in the_rest:
|
||||
the_rest = the_rest.replace("//", "/")
|
||||
rendered_url = f"https://{the_rest}"
|
||||
print(f"{rendered_url=}")
|
||||
raw_response = method(
|
||||
rendered_url,
|
||||
**{
|
||||
arg_name: stuff or {},
|
||||
"headers": make_headers(),
|
||||
},
|
||||
)
|
||||
if not raw_response.ok:
|
||||
raise Exception(
|
||||
f"response: {raw_response.reason}\nrendered_url: '{rendered_url}'\nstuff:{stuff}"
|
||||
)
|
||||
try:
|
||||
response = raw_response.json()["response"]
|
||||
except KeyError:
|
||||
return raw_response.json()
|
||||
if "result" in response:
|
||||
return response["result"]
|
||||
raise Exception(
|
||||
f"response: {response}\nrendered_url: '{rendered_url}'\nstuff:{stuff}"
|
||||
)
|
||||
|
||||
|
||||
def _get_access_token(authorization_code: str | None = None) -> str:
|
||||
if authorization_code:
|
||||
token = _get_token_from_api_with_authorization_code(
|
||||
authorization_code=authorization_code
|
||||
)
|
||||
_save_token(token)
|
||||
return token["access_token"]
|
||||
|
||||
try:
|
||||
token = Token.get()
|
||||
except NoToken:
|
||||
auth_code = _get_code_from_user()
|
||||
token_dict = _get_token_from_api_with_authorization_code(auth_code)
|
||||
_save_token(token_dict)
|
||||
token = Token.get()
|
||||
if not _is_expired(token):
|
||||
return token.access_token
|
||||
|
||||
old_token = token
|
||||
try:
|
||||
token = _get_token_from_api_with_refresh_token(
|
||||
refresh_token=old_token.refresh_token
|
||||
)
|
||||
except AvtFreshException:
|
||||
auth_code = _get_code_from_user()
|
||||
token_dict = _get_token_from_api_with_authorization_code(auth_code)
|
||||
old_token.delete()
|
||||
_save_token(token_dict)
|
||||
token = Token.get()
|
||||
return token.access_token
|
||||
else:
|
||||
old_token.delete()
|
||||
_save_token(token)
|
||||
return token["access_token"]
|
||||
|
||||
|
||||
def _get_code_from_user() -> str:
|
||||
return input(
|
||||
"Please go here and get an auth code: "
|
||||
"https://my.freshbooks.com/#/developer, then enter it here: "
|
||||
)
|
||||
|
||||
|
||||
def _save_token(token_response: dict):
|
||||
Token.make_from_dict(token_response)
|
||||
|
||||
|
||||
def _is_expired(token: Token) -> bool:
|
||||
return dt.datetime.now().timestamp() > token.created_at + token.expires_in
|
||||
|
||||
|
||||
def _get_token_from_api_with_authorization_code(authorization_code: str) -> dict:
|
||||
payload = {
|
||||
"client_secret": SECRET,
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"grant_type": "authorization_code", # get this by visiting
|
||||
"code": authorization_code,
|
||||
}
|
||||
res = requests.post(URL, data=json.dumps(payload), headers=HEADERS)
|
||||
return _return_or_raise(res, payload)
|
||||
|
||||
|
||||
def _return_or_raise(response: requests.Response, payload: dict) -> dict:
|
||||
response_json = response.json()
|
||||
if "error" in response_json:
|
||||
raise AvtFreshException(f"{payload}:\n\n{response_json['error_description']}")
|
||||
del response_json["direct_buy_tokens"]
|
||||
return response_json
|
||||
|
||||
|
||||
def _get_token_from_api_with_refresh_token(refresh_token: str) -> dict:
|
||||
"""
|
||||
:return:
|
||||
{
|
||||
'access_token': <access_token: str>,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': <seconds: int>,
|
||||
'refresh_token': <refresh_token: str>,
|
||||
'scope': 'admin:all:legacy',
|
||||
'created_at': <timestamp>,
|
||||
'direct_buy_tokens': {}
|
||||
}
|
||||
|
||||
"""
|
||||
payload = {
|
||||
"client_secret": SECRET,
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
res = requests.post(URL, data=json.dumps(payload), headers=HEADERS)
|
||||
return _return_or_raise(res, payload)
|
||||
177
avt_fresh/client.py
Normal file
177
avt_fresh/client.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from functools import partial
|
||||
from typing import NamedTuple
|
||||
|
||||
import requests
|
||||
|
||||
from avt_fresh import BASE_URL, REQUEST as __REQUEST, ACCOUNT_ID, make_headers
|
||||
|
||||
|
||||
class FreshbooksContact(NamedTuple):
|
||||
contact_id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: str
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, contactid, fname, lname, email, **_):
|
||||
return cls(contact_id=contactid, first_name=fname, last_name=lname, email=email)
|
||||
|
||||
@property
|
||||
def dict(self):
|
||||
return {
|
||||
"email": self.email,
|
||||
"fname": self.first_name,
|
||||
"lname": self.last_name,
|
||||
}
|
||||
|
||||
|
||||
class FreshbooksClient(NamedTuple):
|
||||
client_id: int
|
||||
email: str
|
||||
organization: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
contacts: dict[str, FreshbooksContact]
|
||||
contact_id_email_lookup: dict[int, str]
|
||||
email_contact_id_lookup: dict[str, int]
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, fname, lname, organization, userid, email, contacts, **_):
|
||||
contacts = {
|
||||
contact["email"]: FreshbooksContact.from_api(**contact)
|
||||
for contact in contacts
|
||||
}
|
||||
contact_id_email_lookup = {
|
||||
contact.contact_id: email for email, contact in contacts.items()
|
||||
}
|
||||
email_contact_id_lookup = {v: k for k, v in contact_id_email_lookup.items()}
|
||||
return cls(
|
||||
client_id=userid,
|
||||
email=email,
|
||||
first_name=fname,
|
||||
last_name=lname,
|
||||
organization=organization,
|
||||
contacts=contacts,
|
||||
contact_id_email_lookup=contact_id_email_lookup,
|
||||
email_contact_id_lookup=email_contact_id_lookup,
|
||||
)
|
||||
|
||||
|
||||
URL = f"{BASE_URL}/accounting/account/{ACCOUNT_ID}/users/clients"
|
||||
_REQUEST = partial(__REQUEST, url=URL)
|
||||
|
||||
|
||||
def _GET(endpoint, params=None):
|
||||
return _REQUEST(method_name="GET", endpoint=endpoint, stuff=params)
|
||||
|
||||
|
||||
def _POST(endpoint, data: dict):
|
||||
return _REQUEST(method_name="POST", endpoint=endpoint, stuff=data)
|
||||
|
||||
|
||||
def _PUT(endpoint, thing_id: int, data: dict):
|
||||
return _REQUEST(method_name="PUT", endpoint=f"{endpoint}/{thing_id}", stuff=data)
|
||||
|
||||
|
||||
class NoResult(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MoreThanOne(Exception):
|
||||
pass
|
||||
|
||||
|
||||
INCLUDE = "include[]=contacts"
|
||||
|
||||
|
||||
def get_freshbooks_client_from_email(email: str) -> FreshbooksClient:
|
||||
try:
|
||||
return get_one(_GET(f"?search[email]={email}&{INCLUDE}"))
|
||||
except NoResult as e:
|
||||
raise NoResult(email) from e
|
||||
|
||||
|
||||
def get_freshbooks_client_from_org_name(org_name: str) -> FreshbooksClient:
|
||||
return get_one(_GET(f"?search[organization_like]={org_name}&{INCLUDE}"))
|
||||
|
||||
|
||||
def get_freshbooks_client_from_client_id(client_id: int) -> FreshbooksClient:
|
||||
return FreshbooksClient.from_api(**_GET(f"{client_id}?{INCLUDE}")["client"])
|
||||
|
||||
|
||||
def get_one(response: dict) -> FreshbooksClient:
|
||||
clients = [FreshbooksClient.from_api(**client) for client in response["clients"]]
|
||||
if len(clients) > 1:
|
||||
print("warning, more than one result, returning the first")
|
||||
elif not clients:
|
||||
raise NoResult
|
||||
return clients[0]
|
||||
|
||||
|
||||
def get_all_clients() -> list[FreshbooksClient]:
|
||||
return _GET(f"?{INCLUDE}")
|
||||
|
||||
|
||||
def delete(client_id) -> None:
|
||||
requests.put(
|
||||
f"{BASE_URL}/accounting/account/{client_id}",
|
||||
json={"client": {"vis_state": 1}},
|
||||
headers=make_headers(),
|
||||
)
|
||||
|
||||
|
||||
def create(
|
||||
first_name: str, last_name: str, email: str, organization: str
|
||||
) -> FreshbooksClient:
|
||||
data = {
|
||||
"client": dict(
|
||||
fname=first_name, lname=last_name, email=email, organization=organization
|
||||
)
|
||||
}
|
||||
client_id = _POST(endpoint="", data=data)["client"]["id"]
|
||||
return get_freshbooks_client_from_client_id(client_id)
|
||||
|
||||
|
||||
def add_contacts(client_id, contacts: list[dict]) -> None:
|
||||
"""contacts: [dict(email, fname, lname)]"""
|
||||
to_update = []
|
||||
new_contacts_email_dict = {c["email"]: c for c in contacts}
|
||||
current_contacts = get_freshbooks_client_from_client_id(client_id).contacts
|
||||
|
||||
if current_contacts:
|
||||
for email, current_contact in current_contacts.items():
|
||||
new_contact = new_contacts_email_dict.get(email)
|
||||
if new_contact is None:
|
||||
to_update.append(current_contact)
|
||||
else:
|
||||
to_update.append(new_contact)
|
||||
del new_contacts_email_dict[new_contact["email"]]
|
||||
|
||||
to_update += list(new_contacts_email_dict.values())
|
||||
update_contacts(client_id, to_update)
|
||||
|
||||
|
||||
def delete_contact(client_id, email) -> None:
|
||||
client = get_freshbooks_client_from_client_id(client_id)
|
||||
contact_to_delete = client.contacts.get(email)
|
||||
if contact_to_delete is not None:
|
||||
return update_contacts(
|
||||
client_id,
|
||||
[
|
||||
client.dict
|
||||
for client in client.contacts.values()
|
||||
if client != contact_to_delete
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def update_contacts(client_id, contacts: list[dict]) -> None:
|
||||
_update_freshbooks_client(client_id, {"contacts": contacts})
|
||||
|
||||
|
||||
def _update_freshbooks_client(client_id, data: dict) -> None:
|
||||
requests.put(
|
||||
f"{BASE_URL}/accounting/account/{client_id}",
|
||||
json={"client": data},
|
||||
headers=make_headers(),
|
||||
)
|
||||
271
avt_fresh/invoice.py
Normal file
271
avt_fresh/invoice.py
Normal file
@@ -0,0 +1,271 @@
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
from functools import partial, lru_cache
|
||||
from typing import NamedTuple
|
||||
|
||||
from avt_fresh import BASE_URL, ACCOUNT_ID, REQUEST as __REQUEST
|
||||
|
||||
URL = f"{BASE_URL}/accounting/account/{ACCOUNT_ID}/invoices/invoices"
|
||||
_REQUEST = partial(__REQUEST, url=URL)
|
||||
|
||||
|
||||
class ArgumentError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MoreThanOne(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DoesntExist(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidField(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _GET(endpoint, params=None):
|
||||
return _REQUEST(method_name="GET", endpoint=endpoint, stuff=params)
|
||||
|
||||
|
||||
def _POST(endpoint, data: dict):
|
||||
return _REQUEST(method_name="POST", endpoint=endpoint, stuff=data)
|
||||
|
||||
|
||||
def _PUT(endpoint, thing_id: int, data: dict):
|
||||
return _REQUEST(method_name="PUT", endpoint=f"{endpoint}/{thing_id}", stuff=data)
|
||||
|
||||
|
||||
class FreshbooksLine(NamedTuple):
|
||||
invoice_id: int
|
||||
client_id: int
|
||||
description: str
|
||||
name: str
|
||||
rate: Decimal
|
||||
line_id: int
|
||||
quantity: Decimal
|
||||
amount: Decimal
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, **kwargs):
|
||||
return cls(
|
||||
invoice_id=kwargs["invoice_id"],
|
||||
client_id=kwargs["client_id"],
|
||||
rate=Decimal(kwargs["unit_cost"]["amount"]),
|
||||
description=kwargs["description"],
|
||||
name=kwargs["name"],
|
||||
quantity=Decimal(kwargs["qty"]),
|
||||
line_id=kwargs["lineid"],
|
||||
amount=Decimal(kwargs["amount"]["amount"]),
|
||||
)
|
||||
|
||||
@property
|
||||
def dict_(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"unit_cost": str(self.rate),
|
||||
"quantity": str(self.quantity),
|
||||
}
|
||||
|
||||
|
||||
class FreshbooksInvoice(NamedTuple):
|
||||
lines: list[FreshbooksLine]
|
||||
notes: str
|
||||
client_id: int
|
||||
date: dt.date
|
||||
invoice_id: int
|
||||
number: str
|
||||
organization: str
|
||||
amount: Decimal
|
||||
status: str
|
||||
amount_outstanding: Decimal
|
||||
po_number: str
|
||||
line_id_line_dict: dict
|
||||
line_description_line_dict: dict
|
||||
line_description_line_id_dict: dict
|
||||
contacts: dict[str, dict]
|
||||
allowed_gateways: list
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, **kwargs):
|
||||
lines = [
|
||||
FreshbooksLine.from_api(
|
||||
invoice_id=kwargs["id"], client_id=kwargs["customerid"], **line
|
||||
)
|
||||
for line in kwargs["lines"]
|
||||
]
|
||||
|
||||
line_description_line_id_dict = {
|
||||
line.description: line.line_id for line in lines
|
||||
}
|
||||
line_id_line_dict = {line.line_id: line for line in lines}
|
||||
line_description_line_dict = {line.description: line for line in lines}
|
||||
|
||||
return cls(
|
||||
lines=lines,
|
||||
line_description_line_id_dict=line_description_line_id_dict,
|
||||
line_description_line_dict=line_description_line_dict,
|
||||
line_id_line_dict=line_id_line_dict,
|
||||
notes=kwargs["notes"],
|
||||
client_id=kwargs["customerid"],
|
||||
date=dt.date.fromisoformat(kwargs["create_date"]),
|
||||
invoice_id=kwargs["id"],
|
||||
po_number=kwargs["po_number"],
|
||||
number=kwargs["invoice_number"],
|
||||
organization=kwargs["organization"],
|
||||
allowed_gateways=kwargs["allowed_gateways"],
|
||||
amount=Decimal(kwargs["amount"]["amount"]),
|
||||
amount_outstanding=Decimal(kwargs["outstanding"]["amount"]),
|
||||
contacts={contact["email"]: contact for contact in kwargs["contacts"]},
|
||||
status=kwargs["v3_status"],
|
||||
)
|
||||
|
||||
|
||||
def get_all_draft_invoices():
|
||||
return get(status="draft")
|
||||
|
||||
|
||||
def get_all_invoices_for_org_name(org_name: str) -> list[FreshbooksInvoice]:
|
||||
return get(org_name=org_name)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_all_invoices_for_client_id(client_id: int) -> list[FreshbooksInvoice]:
|
||||
return get(client_id=client_id)
|
||||
|
||||
|
||||
def get_draft_invoices_for_client_id(client_id: int) -> list[FreshbooksInvoice]:
|
||||
return get(client_id=client_id, status="draft")
|
||||
|
||||
|
||||
def get_one(invoice_id) -> FreshbooksInvoice:
|
||||
invoices = get(invoice_id)
|
||||
if invoices:
|
||||
if len(invoices) > 1:
|
||||
raise MoreThanOne
|
||||
return invoices[0]
|
||||
raise DoesntExist
|
||||
|
||||
|
||||
def get(
|
||||
invoice_id=None,
|
||||
client_id=None,
|
||||
org_name=None,
|
||||
status=None,
|
||||
) -> list[FreshbooksInvoice]:
|
||||
|
||||
if client_id is not None and org_name is not None:
|
||||
raise ArgumentError("Please provide either client_id or org_name")
|
||||
|
||||
if invoice_id is not None and any(
|
||||
arg is not None for arg in (client_id, org_name, status)
|
||||
):
|
||||
raise ArgumentError(
|
||||
"Please provide invoice_id and no other args, or else don't provide invoice_id"
|
||||
)
|
||||
|
||||
full_url = ""
|
||||
sep = "?"
|
||||
|
||||
if invoice_id is not None:
|
||||
full_url += f"/{invoice_id}"
|
||||
|
||||
if client_id is not None:
|
||||
full_url += f"{sep}search[customerid]={client_id}"
|
||||
sep = "&"
|
||||
|
||||
if status is not None:
|
||||
full_url += f"{sep}search[v3_status]={status}"
|
||||
sep = "&"
|
||||
|
||||
response = _GET(full_url)
|
||||
try:
|
||||
num_results = response["total"]
|
||||
except KeyError:
|
||||
full_url += (
|
||||
f"{sep}include[]=lines&include[]=contacts&include[]=allowed_gateways"
|
||||
)
|
||||
else:
|
||||
if not num_results:
|
||||
return []
|
||||
full_url += (
|
||||
f"{sep}per_page={num_results}"
|
||||
f"&include[]=lines&include[]=contacts&include[]=allowed_gateways"
|
||||
)
|
||||
|
||||
result = _GET(full_url)
|
||||
if "invoice" in result:
|
||||
return [FreshbooksInvoice.from_api(**result["invoice"])]
|
||||
invoices = result["invoices"]
|
||||
|
||||
if org_name is not None:
|
||||
invoices = [i for i in invoices if org_name == i["current_organization"]]
|
||||
|
||||
fb_invoices = []
|
||||
for invoice in invoices:
|
||||
try:
|
||||
fb_invoice = FreshbooksInvoice.from_api(**invoice)
|
||||
except ValueError as e:
|
||||
raise InvalidField(f"{invoice}") from e
|
||||
else:
|
||||
fb_invoices.append(fb_invoice)
|
||||
return fb_invoices
|
||||
|
||||
|
||||
STATUS_STRING_INT_LOOKUP = {
|
||||
"draft": 1,
|
||||
"paid": 4,
|
||||
}
|
||||
|
||||
|
||||
def create(
|
||||
client_id: int,
|
||||
notes: str,
|
||||
lines: list[dict],
|
||||
status: str | int,
|
||||
contacts: list[dict] | None = None,
|
||||
po_number=None,
|
||||
create_date=None,
|
||||
) -> dict:
|
||||
"""
|
||||
`lines`
|
||||
The dictionaries must contain entries for `name`, `description`, `unit_cost`, and `qty`.
|
||||
The values must be JSON-serializable, so no `Decimal`s for example (all strings is fine).
|
||||
`contacts`
|
||||
Each of these dictionaries should simply be `{'contactid': <contactid>}`.
|
||||
`status`
|
||||
Status can be any of the `v3_status` values as a `str` or `1` or `4` (draft/paid).
|
||||
"""
|
||||
create_date = create_date or str(dt.date.today())
|
||||
if isinstance(status, str):
|
||||
status = STATUS_STRING_INT_LOOKUP[status]
|
||||
data = dict(
|
||||
invoice=dict(
|
||||
customerid=client_id, # type: ignore
|
||||
notes=notes,
|
||||
status=status, # type: ignore
|
||||
lines=lines, # type: ignore
|
||||
create_date=create_date,
|
||||
allowed_gateway_name="Stripe",
|
||||
)
|
||||
)
|
||||
if contacts:
|
||||
data["invoice"]["contacts"] = contacts
|
||||
if po_number:
|
||||
data["invoice"]["po_number"] = po_number
|
||||
|
||||
return _POST("", data=data)
|
||||
|
||||
|
||||
def update(invoice_id, **kwargs) -> dict:
|
||||
return _PUT("", invoice_id, dict(invoice=kwargs))
|
||||
|
||||
|
||||
def delete(invoice_id) -> dict:
|
||||
return _PUT("", invoice_id, dict(invoice=dict(vis_state=1)))
|
||||
|
||||
|
||||
def send(invoice_id) -> dict:
|
||||
return _PUT("", invoice_id, {"invoice": {"action_email": True}})
|
||||
30
avt_fresh/payments.py
Normal file
30
avt_fresh/payments.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from functools import partial
|
||||
|
||||
from . import BASE_URL, ACCOUNT_ID, REQUEST as __REQUEST
|
||||
|
||||
URL = f"{BASE_URL}/payments/account/{ACCOUNT_ID}"
|
||||
_REQUEST = partial(__REQUEST, url=URL)
|
||||
|
||||
|
||||
def _GET(endpoint, params=None):
|
||||
return _REQUEST(method_name="GET", endpoint=endpoint, stuff=params)
|
||||
|
||||
|
||||
def _POST(endpoint, data: dict):
|
||||
return _REQUEST(method_name="POST", endpoint=endpoint, stuff=data)
|
||||
|
||||
|
||||
def get_default_payment_options():
|
||||
return _GET("payment_options?entity_type=invoice")
|
||||
|
||||
|
||||
def add_payment_option_to_invoice(invoice_id, gateway_name="stripe"):
|
||||
return _POST(
|
||||
f"invoice/{invoice_id}/payment_options",
|
||||
data={
|
||||
"gateway_name": gateway_name,
|
||||
"entity_id": invoice_id,
|
||||
"entity_type": "invoice",
|
||||
"has_credit_card": True,
|
||||
},
|
||||
)
|
||||
55
avt_fresh/token.py
Normal file
55
avt_fresh/token.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class NoToken(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OnlyOneToken(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Token(NamedTuple):
|
||||
id: int
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
scope: str
|
||||
created_at: int
|
||||
|
||||
@classmethod
|
||||
def _get_all(cls) -> dict:
|
||||
TOKEN_PATH = Path("~/freshbooks_oauth_token.json").expanduser()
|
||||
if not TOKEN_PATH.exists():
|
||||
raise NoToken
|
||||
with TOKEN_PATH.open(encoding="utf-8") as fin:
|
||||
return json.load(fin)
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> "Token":
|
||||
tokens = cls._get_all()
|
||||
return cls(**list(tokens.values())[-1])
|
||||
|
||||
def delete(self) -> None:
|
||||
tokens = self._get_all()
|
||||
if len(tokens) == 0:
|
||||
raise OnlyOneToken
|
||||
id_ = self.id
|
||||
del tokens[id_]
|
||||
self.make_from_dict(tokens)
|
||||
|
||||
@classmethod
|
||||
def make_from_dict(cls, token_dict: dict) -> None:
|
||||
TOKEN_PATH = Path("~/freshbooks_oauth_token.json").expanduser()
|
||||
if not TOKEN_PATH.exists():
|
||||
print(f"token JSON didn't exist, creating it at {TOKEN_PATH}:")
|
||||
TOKEN_PATH.touch()
|
||||
id_ = str(uuid4())
|
||||
token_dict["id"] = id_
|
||||
token_dict = {id: token_dict}
|
||||
with TOKEN_PATH.open("w", encoding="utf-8") as fout:
|
||||
json.dump(token_dict, fout)
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
certifi==2021.10.8
|
||||
charset-normalizer==2.0.12
|
||||
idna==3.3
|
||||
python-dotenv==0.20.0
|
||||
requests==2.27.1
|
||||
urllib3==1.26.9
|
||||
6
setup.cfg
Normal file
6
setup.cfg
Normal file
@@ -0,0 +1,6 @@
|
||||
[metadata]
|
||||
description-file=README.md
|
||||
license_files=LICENSE.txt
|
||||
long_description=file: README.md
|
||||
long_description_content_type=text/markdown
|
||||
|
||||
18
setup.py
Normal file
18
setup.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from distutils.core import setup
|
||||
|
||||
setup(
|
||||
name="avt_fresh",
|
||||
author="Zev Averbach",
|
||||
author_email="zev@averba.ch",
|
||||
version="0.0.3",
|
||||
license="MIT",
|
||||
python_requires=">3.10.0",
|
||||
keywords='freshbooks API',
|
||||
install_requires=[
|
||||
'requests',
|
||||
'python-dotenv',
|
||||
],
|
||||
packages=[
|
||||
"avt_fresh",
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user