from collections import defaultdict from datetime import date, datetime from functools import lru_cache from typing import List, Optional from bs4 import BeautifulSoup from pydantic import BaseModel, ConfigDict from rich import box from rich.columns import Columns from rich.console import Group, Text from rich.panel import Panel from rich.table import Column, Table from edgar._party import Address from edgar.core import get_bool from edgar.entity import Company from edgar.formatting import yes_no from edgar.reference import states from edgar.richtools import repr_rich from edgar.xmltools import child_text __all__ = ['FormC', 'Signer', 'FundingPortal'] class FilerInformation(BaseModel): model_config = ConfigDict(frozen=True) cik: str ccc: str confirming_copy_flag: bool return_copy_flag: bool override_internet_flag: bool live_or_test: bool period: Optional[date] = None @property @lru_cache(maxsize=1) def company(self): return Company(self.cik) class FundingPortal(BaseModel): """The intermediary the company is using to raise funds""" name: str cik: str crd: Optional[str] file_number: str class IssuerInformation(BaseModel): name: str address: Address website: str co_issuer: bool funding_portal: Optional[FundingPortal] legal_status: str jurisdiction: str date_of_incorporation: date @property def incorporated(self): return f"{self.date_of_incorporation or ''} {self.jurisdiction or ''}" class OfferingInformation(BaseModel): """ A fee equal of 3% in cash of the aggregate amount raised by the Company, payable at each closing of the Offering. No Other Membership Interests 41666 1.20000 Determined arbitrarily by the issuer 50000.00 Y First-come, first-served basis At issuer's discretion, with priority given to StartEngine Owners 950000.00 12-31-2024 """ compensation_amount: str financial_interest: Optional[str] security_offered_type: Optional[str] security_offered_other_desc: Optional[str] no_of_security_offered: Optional[str] price: Optional[str] price_determination_method: Optional[str] offering_amount: Optional[float] over_subscription_accepted: Optional[str] over_subscription_allocation_type: Optional[str] desc_over_subscription: Optional[str] maximum_offering_amount: Optional[float] deadline_date: Optional[date] class AnnualReportDisclosure(BaseModel): """ 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 """ current_employees: int total_asset_most_recent_fiscal_year: float total_asset_prior_fiscal_year: float cash_equi_most_recent_fiscal_year: float cash_equi_prior_fiscal_year: float act_received_most_recent_fiscal_year: float act_received_prior_fiscal_year: float short_term_debt_most_recent_fiscal_year: float short_term_debt_prior_fiscal_year: float long_term_debt_most_recent_fiscal_year: float long_term_debt_prior_fiscal_year: float revenue_most_recent_fiscal_year: float revenue_prior_fiscal_year: float cost_goods_sold_most_recent_fiscal_year: float cost_goods_sold_prior_fiscal_year: float tax_paid_most_recent_fiscal_year: float tax_paid_prior_fiscal_year: float net_income_most_recent_fiscal_year: float net_income_prior_fiscal_year: float offering_jurisdictions: List[str] @property def is_offered_in_all_states(self): return set(self.offering_jurisdictions).issuperset(states.keys()) def __rich__(self): annual_report_table = Table(Column("", style='bold'), Column("Current Fiscal Year", style="bold"), Column("Previous Fiscal Year"), box=box.SIMPLE, row_styles=["", "bold"]) annual_report_table.add_row("Current Employees", f"{self.current_employees:,.0f}", "") annual_report_table.add_row("Total Asset", f"${self.total_asset_most_recent_fiscal_year:,.2f}", f"${self.total_asset_prior_fiscal_year:,.2f}") annual_report_table.add_row("Cash Equivalent", f"${self.cash_equi_most_recent_fiscal_year:,.2f}", f"${self.cash_equi_prior_fiscal_year:,.2f}") annual_report_table.add_row("Accounts Receivable", f"${self.act_received_most_recent_fiscal_year:,.2f}", f"${self.act_received_prior_fiscal_year:,.2f}") annual_report_table.add_row("Short Term Debt", f"${self.short_term_debt_most_recent_fiscal_year:,.2f}", f"${self.short_term_debt_prior_fiscal_year:,.2f}") annual_report_table.add_row("Long Term Debt", f"${self.long_term_debt_most_recent_fiscal_year:,.2f}", f"${self.long_term_debt_prior_fiscal_year:,.2f}") annual_report_table.add_row("Revenue", f"${self.revenue_most_recent_fiscal_year:,.2f}", f"${self.revenue_prior_fiscal_year:,.2f}") annual_report_table.add_row("Cost of Goods Sold", f"${self.cost_goods_sold_most_recent_fiscal_year:,.2f}", f"${self.cost_goods_sold_prior_fiscal_year:,.2f}") annual_report_table.add_row("Tax Paid", f"${self.tax_paid_most_recent_fiscal_year:,.2f}", f"${self.tax_paid_prior_fiscal_year:,.2f}") annual_report_table.add_row("Net Income", f"${self.net_income_most_recent_fiscal_year:,.2f}", f"${self.net_income_prior_fiscal_year:,.2f}") # Jurisdictions jurisdiction_table = Table(Column("Offered In", style="bold"), box=box.SIMPLE, row_styles=["", "bold"]) if self.is_offered_in_all_states: juris_description = "All 50 States" jurisdiction_table.add_row(juris_description) else: jurisdiction_lists = split_list(self.offering_jurisdictions, chunk_size=25) for _index, jurisdictions in enumerate(jurisdiction_lists): jurisdiction_table.add_row(", ".join(jurisdictions)) return Group(annual_report_table, jurisdiction_table) def __repr__(self): return repr_rich(self.__rich__()) class PersonSignature(BaseModel): signature: str title: str date: date class IssuerSignature(BaseModel): issuer: str title: str signature: str class Signer(BaseModel): name: str titles: List[str] class SignatureInfo(BaseModel): issuer_signature: IssuerSignature signatures: List[PersonSignature] @property def signers(self) -> List[Signer]: signer_dict = defaultdict(list) for signature in self.signatures: signer_dict[signature.signature].append(signature.title) signer_dict[self.issuer_signature.signature].append(self.issuer_signature.title) return [Signer(name=name, titles=list(set(titles))) for name, titles in signer_dict.items()] def split_list(states, chunk_size=10): # Split a list into sublist of size chunk_size return [states[i:i + chunk_size] for i in range(0, len(states), chunk_size)] def maybe_float(value): if not value: return 0.00 try: return float(value) except ValueError: return 0.00 def maybe_date(value): if not value: return None try: return FormC.parse_date(value) except ValueError: return None class FormC: def __init__(self, filer_information: FilerInformation, issuer_information: IssuerInformation, offering_information: Optional[OfferingInformation], annual_report_disclosure: Optional[AnnualReportDisclosure], signature_info: SignatureInfo, form: str): self.filer_information: FilerInformation = filer_information self.issuer_information: IssuerInformation = issuer_information self.offering_information: OfferingInformation = offering_information self.annual_report_disclosure: Optional[AnnualReportDisclosure] = annual_report_disclosure self.signature_info: SignatureInfo = signature_info self.form = form @property def description(self): desc = "" if self.form == "C": desc = "Form C - Offering" elif self.form == "C/A": desc = "Form C/A - Offering Amendment" elif self.form == "C-U": desc = "Form C-U - Offering Progress Update" elif self.form == "C-U/A": desc = "Form C-U/A - Offering Progress Update Amendment" elif self.form == "C-AR": desc = "Form C-AR - Offering Annual Report" elif self.form == "C-AR/A": desc = "Form C-AR/A - Offering Annual Report Amendment" elif self.form == "C-TR": desc = "Form C-TR - Offering Termination Report" return desc @staticmethod def parse_date(date_str) -> date: """ The date is in the format MM-DD-YYYY """ return datetime.strptime(date_str, "%m-%d-%Y").date() @staticmethod def format_date(date_value: date): """ Format as April 1, 2021 """ return date_value.strftime("%B %d, %Y") @classmethod def from_xml(cls, offering_xml: str, form: str): soup = BeautifulSoup(offering_xml, "xml") root = soup.find('edgarSubmission') # Header Data header_data = root.find('headerData') filer_info_el = header_data.find('filerInfo') filer_el = filer_info_el.find('filer') # Flags flags_tag = header_data.find('flags') confirming_copy_flag = child_text(flags_tag, 'confirmingCopyFlag') == 'true' return_copy_flag = child_text(flags_tag, 'returnCopyFlag') == 'true' override_internet_flag = child_text(flags_tag, 'overrideInternetFlag') == 'true' period = child_text(header_data, 'period') filer_information = FilerInformation( cik=filer_el.find('filerCik').text, ccc=filer_el.find('filerCik').text, confirming_copy_flag=confirming_copy_flag, return_copy_flag=return_copy_flag, override_internet_flag=override_internet_flag, live_or_test=child_text(filer_el, 'testOrLive') == 'LIVE', period=FormC.parse_date(period) if period else None ) # Form form_data_tag = root.find('formData') # Issuer Information issuer_information_tag = form_data_tag.find('issuerInformation') issuer_info_tag = issuer_information_tag.find('issuerInfo') issuer_address_tag = issuer_info_tag.find('issuerAddress') address = Address( street1=child_text(issuer_address_tag, 'street1'), street2=child_text(issuer_address_tag, 'street2'), city=child_text(issuer_address_tag, 'city'), state_or_country=child_text(issuer_address_tag, 'stateOrCountry'), zipcode=child_text(issuer_address_tag, 'zipCode') ) legal_status = child_text(issuer_info_tag, 'legalStatusForm') jurisdiction = child_text(issuer_info_tag, 'jurisdictionOrganization') date_of_incorporation = child_text(issuer_info_tag, 'dateIncorporation') # Funding Portal data funding_portal_cik = child_text(issuer_information_tag, 'commissionCik') funding_portal = FundingPortal( name=child_text(issuer_information_tag, 'companyName'), cik=funding_portal_cik, file_number=child_text(issuer_information_tag, 'commissionFileNumber'), crd=child_text(issuer_information_tag, 'crdNumber') ) if funding_portal_cik else None issuer_information = IssuerInformation( name=child_text(issuer_info_tag, 'nameOfIssuer'), address=address, website=child_text(issuer_info_tag, 'issuerWebsite'), co_issuer=get_bool(child_text(issuer_information_tag, 'isCoIssuer')), funding_portal=funding_portal, legal_status=legal_status, jurisdiction=jurisdiction, date_of_incorporation=FormC.parse_date(date_of_incorporation) ) # Offering Information offering_info_tag = form_data_tag.find('offeringInformation') if offering_info_tag is not None and offering_info_tag.contents and offering_info_tag.get_text(strip=True): offering_information = OfferingInformation( compensation_amount=child_text(offering_info_tag, 'compensationAmount'), financial_interest=child_text(offering_info_tag, 'financialInterest'), security_offered_type=child_text(offering_info_tag, 'securityOfferedType'), security_offered_other_desc=child_text(offering_info_tag, 'securityOfferedOtherDesc'), no_of_security_offered=child_text(offering_info_tag, 'noOfSecurityOffered'), price=child_text(offering_info_tag, 'price'), price_determination_method=child_text(offering_info_tag, 'priceDeterminationMethod'), offering_amount=maybe_float(child_text(offering_info_tag, 'offeringAmount')), over_subscription_accepted=child_text(offering_info_tag, 'overSubscriptionAccepted'), over_subscription_allocation_type=child_text(offering_info_tag, 'overSubscriptionAllocationType'), desc_over_subscription=child_text(offering_info_tag, 'descOverSubscription'), maximum_offering_amount=maybe_float(child_text(offering_info_tag, 'maximumOfferingAmount')), deadline_date=maybe_date(child_text(offering_info_tag, 'deadlineDate')) ) else: offering_information = None # Annual Report Disclosure annual_report_disclosure_tag = form_data_tag.find('annualReportDisclosureRequirements') # If the tag is not None and not Empty e.g. if annual_report_disclosure_tag and annual_report_disclosure_tag.contents: annual_report_disclosure = AnnualReportDisclosure( current_employees=int(float(child_text(annual_report_disclosure_tag, 'currentEmployees') or "0.00")), total_asset_most_recent_fiscal_year=maybe_float(child_text(annual_report_disclosure_tag, 'totalAssetMostRecentFiscalYear')), total_asset_prior_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'totalAssetPriorFiscalYear')), cash_equi_most_recent_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'cashEquiMostRecentFiscalYear')), cash_equi_prior_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'cashEquiPriorFiscalYear')), act_received_most_recent_fiscal_year=maybe_float(child_text(annual_report_disclosure_tag, 'actReceivedMostRecentFiscalYear')), act_received_prior_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'actReceivedPriorFiscalYear')), short_term_debt_most_recent_fiscal_year=maybe_float(child_text(annual_report_disclosure_tag, 'shortTermDebtMostRecentFiscalYear')), short_term_debt_prior_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'shortTermDebtPriorFiscalYear')), long_term_debt_most_recent_fiscal_year=maybe_float(child_text(annual_report_disclosure_tag, 'longTermDebtMostRecentFiscalYear')), long_term_debt_prior_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'longTermDebtPriorFiscalYear')), revenue_most_recent_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'revenueMostRecentFiscalYear')), revenue_prior_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'revenuePriorFiscalYear')), cost_goods_sold_most_recent_fiscal_year=maybe_float(child_text(annual_report_disclosure_tag, 'costGoodsSoldMostRecentFiscalYear')), cost_goods_sold_prior_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'costGoodsSoldPriorFiscalYear')), tax_paid_most_recent_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'taxPaidMostRecentFiscalYear')), tax_paid_prior_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'taxPaidPriorFiscalYear')), net_income_most_recent_fiscal_year=maybe_float(child_text(annual_report_disclosure_tag, 'netIncomeMostRecentFiscalYear')), net_income_prior_fiscal_year=maybe_float( child_text(annual_report_disclosure_tag, 'netIncomePriorFiscalYear')), offering_jurisdictions=[el.text for el in annual_report_disclosure_tag.find_all('issueJurisdictionSecuritiesOffering')] ) else: annual_report_disclosure = None # Signature Block signature_block_tag = root.find("signatureInfo") issuer_signature_tag = signature_block_tag.find("issuerSignature") signature_info = SignatureInfo( issuer_signature=IssuerSignature( issuer=child_text(issuer_signature_tag, "issuer"), signature=child_text(issuer_signature_tag, "issuerSignature"), title=child_text(issuer_signature_tag, "issuerTitle") ), signatures=[ PersonSignature( signature=child_text(person_signature_tag, "personSignature"), title=child_text(person_signature_tag, "personTitle"), date=FormC.parse_date(child_text(person_signature_tag, "signatureDate")) ) for person_signature_tag in signature_block_tag.find_all('signaturePerson') ] ) return cls(filer_information=filer_information, issuer_information=issuer_information, offering_information=offering_information, annual_report_disclosure=annual_report_disclosure, signature_info=signature_info, form=form) def __rich__(self): # Filer Panel filer_table = Table("Company", "CIK", box=box.SIMPLE) if self.filer_information.period: filer_table.add_column("Period") filer_table.add_row(self.filer_information.company.name, self.filer_information.cik, FormC.format_date(self.filer_information.period)) else: filer_table.add_row(self.filer_information.company.name, self.filer_information.cik) filer_panel = Panel(filer_table, title=Text("Filer", style="bold deep_sky_blue1"), box=box.ROUNDED) # Issuers issuer_table = Table(Column("Issuer", style="bold"), "Legal Status", "Incorporated", "Jurisdiction", box=box.SIMPLE) issuer_table.add_row(self.issuer_information.name, self.issuer_information.legal_status, FormC.format_date(self.issuer_information.date_of_incorporation), states.get(self.issuer_information.jurisdiction, self.issuer_information.jurisdiction)) # Address Panel address_panel = Panel( Text(str(self.issuer_information.address)), title='\U0001F3E2 Business Address', width=40) # Address and website contact_columns = Columns([address_panel, Panel(Text(self.issuer_information.website), title="Website")]) issuer_panel = Panel( Group(*[issuer_table, contact_columns]), title=Text("Issuer", style="bold deep_sky_blue1"), box=box.ROUNDED ) # Funding Portal funding_portal_panel = None if self.issuer_information.funding_portal is not None: intermediary_table = Table(Column("Name", style="bold"), "CIK", "CRD Number", "File Number", box=box.SIMPLE) intermediary_table.add_row( self.issuer_information.funding_portal.name, self.issuer_information.funding_portal.cik, self.issuer_information.funding_portal.crd or "", self.issuer_information.funding_portal.file_number) funding_portal_panel = Panel( intermediary_table, title=Text("CrowdFunding Portal", style="bold deep_sky_blue1"), box=box.ROUNDED ) offering_panel = None if self.offering_information: # Offering Information offering_table = Table(Column("", style='bold'), "", box=box.SIMPLE, row_styles=["", "bold"]) offering_table.add_row("Compensation Amount", self.offering_information.compensation_amount) offering_table.add_row("Financial Interest", self.offering_information.financial_interest) offering_table.add_row("Type of Security", self.offering_information.security_offered_type) offering_table.add_row("Number of Securities", self.offering_information.no_of_security_offered) offering_table.add_row("Price", self.offering_information.price) offering_table.add_row("Price (or Method for Determining Price)", self.offering_information.price_determination_method) offering_table.add_row("Target Offering Amount", f"${self.offering_information.offering_amount:,.2f}") offering_table.add_row("Maximum Offering Amount", f"${self.offering_information.maximum_offering_amount:,.2f}") offering_table.add_row("Over-Subscription Accepted", yes_no(self.offering_information.over_subscription_accepted == "Y")), offering_table.add_row("How will over-subscriptions be allocated", self.offering_information.over_subscription_allocation_type) offering_table.add_row("Describe over-subscription plan", self.offering_information.desc_over_subscription) offering_table.add_row("Deadline Date", FormC.format_date( self.offering_information.deadline_date) if self.offering_information.deadline_date else "") offering_panel = Panel( offering_table, title=Text("Offering Information", style="bold deep_sky_blue1"), box=box.ROUNDED ) # Annual Report Disclosure if self.annual_report_disclosure: annual_report_panel = Panel( self.annual_report_disclosure.__rich__(), title=Text("Annual Report Disclosures", style="bold deep_sky_blue1"), box=box.ROUNDED ) else: annual_report_panel = None # Signature Info # The signature of the issuer issuer_signature_table = Table(Column("Signature", style="bold"), "Title", "Issuer", box=box.SIMPLE) issuer_signature_table.add_row(self.signature_info.issuer_signature.signature, self.signature_info.issuer_signature.title, self.signature_info.issuer_signature.issuer) # Person Signatures person_signature_table = Table(Column("Signature", style="bold"), "Title", "Date", box=box.SIMPLE, row_styles=["", "bold"]) for signature in self.signature_info.signatures: person_signature_table.add_row(signature.signature, signature.title, FormC.format_date(signature.date)) signature_panel = Panel( Group( Panel(issuer_signature_table, box=box.ROUNDED, title="Issuer"), Panel(person_signature_table, box=box.ROUNDED, title="Persons") ), title=Text("Signatures", style="bold deep_sky_blue1"), box=box.ROUNDED ) renderables = [ filer_panel, issuer_panel, ] if funding_portal_panel is not None: renderables.append(funding_portal_panel) if self.offering_information is not None: renderables.append(offering_panel) if self.annual_report_disclosure is not None: renderables.append(annual_report_panel) renderables.append(signature_panel) panel = Panel( Group(*renderables), title=Text(self.description, style="bold dark_sea_green4"), ) return panel def __repr__(self): return repr_rich(self.__rich__())