This project is a backend data integration system built for BBGI (Beasley Media Group), a radio broadcasting company. The system pulls advertising and financial data from Marketron (their traffic/order management platform), processes and unifies it, then pushes it into NetSuite (their accounting/ERP system).
Alongside the data pipeline, there is a Flask-based internal web application that gives the AR (Accounts Receivable) and operations team tools for reconciliation, manual contact mapping, and related tasks. The project also handles automated aged-debt email notifications and invoice PDF
delivery to Tesorio (an AR automation platform) via SFTP.
The codebase went through a refactor mid-project to move from a flat core/ directory structure to a more modular src/integrations/ layout.
BBGI sells radio advertising across multiple markets. Their sales and order management runs in Marketron, a broadcasting-specific platform that manages clients, orders, invoices, account executives, and aging data. However, their financial reporting and accounting runs in NetSuite. The two systems did not talk to each other automatically. Marketron data needed to be extracted, cleaned up, and loaded into NetSuite as proper financial records — specifically customers, employees (sales reps), non-inventory items (sale types), and invoices.
The two systems did not talk to each other automatically. Marketron data needed to be extracted, cleaned up, and loaded into NetSuite as proper financial records — specifically customers, employees (sales reps), non-inventory items (sale types), and invoices.
On top of that, the AR team needed:
The solution is a Python application that acts as a bridge between Marketron, MongoDB, and NetSuite. The core of it is a data pipeline that runs on a schedule (via Render’s cron job or manual trigger) and does the following:
The Flask web app provides an internal UI for the team to:
Email notifications were built to send personalized aging reports to AEs, the AR team, and clients using Mandrill (Mailchimp Transactional). Emails are stored as previews in MongoDB with a batch UUID before sending, so they can be reviewed first.
| Category | Technology | Purpose |
|---|---|---|
| Language | Python 3.12 | All backend logic, pipeline, API calls |
| Web Framework | Flask + Gunicorn | Internal web application |
| Database | MongoDB Atlas (PyMongo) | Data lake / intermediate storage |
| Data Source | Marketron REST API | Source of all advertising/order data |
| Data Destination | NetSuite (Custom RESTlet) | ERP / accounting system |
| NetSuite Auth | OAuth 1.0 (Requests-OAuthlib) | Authenticated calls to NetSuite RESTlet |
| NetSuite RESTlet | SuiteScript 2.x (JavaScript) | Custom script deployed inside NetSuite to handle create/update/delete |
| CRM | Freshworks Sales CRM API | Pulls AP contact email data for clients |
| SFTP | pysftp | Uploads invoice PDFs to Tesorio |
| Mandrill / Mailchimp Transactional | Transactional email delivery (aging reports) | |
| Data Processing | pandas, NumPy | CSV handling, data manipulation |
| Error Tracking | Sentry SDK | Runtime error monitoring |
| Config | python-dotenv | Environment variable management |
| Deployment | Render (PaaS) | Hosting the web service |
| Templating | Jinja2 | HTML email and web UI templates |
| Retry Logic | retrying library | Retry on MongoDB auto-reconnect errors |
The system has a simple but multi-part flow. The pipeline side runs as a background job; the web UI runs as a Flask web service.
Marketron API → Python (utils_simple.py) — paginated REST calls, incremental date filtering
↓
MongoDB Atlas — raw collections (mktr__invoices, mktr__clients, mktr__orders, etc.)
↓
Augmentation scripts — attach AE emails, compute discounts, determine netsuite_transactionID, download
PDFs
↓
MongoDB Atlas — augmented collections (mktr__invoicesAugmented, etc.)
↓
netsuite_restlet_all.py — transforms and batches data, calls NetSuite via OAuth1
↓
NetSuite RESTlet (mrktrestlet.js) — creates or updates customers, employees, items, invoices
↓
Tesorio SFTP — invoice PDFs uploaded via pysftp
Browser → Flask app (app.py) — session-based login, routes
↓
MongoDB Atlas — reads/writes manual_contact_mapping, email_previews, etc.
↓
Reconciliation engine (reconcile_systems_new_format.py) — compares uploaded Marketron CSV vs
NetSuite CSV
Purpose: Central configuration for all data types in the system.
Defines a DataMapping dataclass for each Marketron data type (markets, clients, invoices, orders, AEs, stations, spots, payments, etc.). Each mapping holds the Marketron API URL, the MongoDB collection name, whether the table has daily changes, pagination settings, and which NetSuite record type it maps to. The TableName enum gives stable names to all collections. This file is imported almost everywhere and acts as the single source of truth for the data model.
Purpose: Pulls data from the Marketron API with pagination and incremental filtering.
The paginated_get() function handles all API calls. It supports multiple pull types ( DataPullType : full, last 7 days, last 30 days, etc.) and uses date-based parameters ( minimumModifiedUtc , minimumDate , etc.) to only fetch records changed since the last run. API calls are made per market (Marketron data is organized by company/market ID), so the system iterates over all markets and merges results. A timed_lru_cache decorator avoids re-fetching data within the same hour.
Purpose: All read and write operations to MongoDB Atlas.
Provides functions to upload, download, and transform data in MongoDB collections. Key functions: upload_data_to_mongo_helper() for batch upsert, download_mongo_table_to_dict() / download_mongo_table_to_list() for reads, and transform_and_reupload_data() for in-place data transformations. Uses replace_id_with_hash() to generate stable document IDs from data content. All batch writes go through a retry decorator to handle transient MongoDB reconnect errors. The database name is bbgi_marketron .
Purpose: Enriches raw invoice data before it gets sent to NetSuite.
Fetches raw invoices from MongoDB, then for each invoice: attaches the Account Executive’s email address, joins order line data to compute discount percentages per line, determines the correct netsuite_transactionID (handles duplicate transaction numbers across markets by appending the market number when needed), and downloads the invoice PDF from Marketron and uploads it to Tesorio SFTP. Results are stored back into MongoDB in the mktr__invoicesAugmented collection. Processing is done in batches using PyMongo’s bulk_write .
Purpose: Python side of the NetSuite integration. Handles OAuth1 auth and HTTP calls to the RESTlet.
Creates an OAuth1Session using consumer key/secret and token key/secret loaded from environment variables. Exposes typed functions: create_record() , update_record() , get_record() , search_record() , delete_record() , and update_invoice_lines() . Supports both sandbox ( 9639742-sb1 ) and production ( 9639742 ) NetSuite account URLs. All calls go to a single RESTlet endpoint (script 769, deploy 2).
Purpose: The SuiteScript 2.x code deployed inside NetSuite that receives requests and creates/updates records.
This JavaScript file is manually deployed inside NetSuite via the Script editor UI (script ID 874 in sandbox). It handles multiple method values in the incoming JSON: POST (create), update , search , GET , delete , updatelines , and debug . The updatelines method allows updating individual line items on an existing invoice by lineuniquekey without replacing the whole record.
Purpose: Reads augmented data from MongoDB, transforms it to NetSuite field format, and calls the RESTlet in batches.
Contains transform functions for each record type: transform_customer_data() , transform_employee_data() , transform_item_data() , transform_invoice_data() . The invoice transformer is the most complex — it resolves cross-references (customer ID, AE/sales rep ID, sale type item ID, revenue source classification), maps Marketron fields to NetSuite fields, handles trade invoices, and applies discount logic. Existing records are looked up by externalid to decide whether to create or update. Failures are logged to Sentry and to per-run log files in core/sync_logs/ .
Purpose: Entry point for the full daily sync.
Calls the steps in order: pull updated Marketron data for each table → upload to MongoDB → run invoice augmentation → check Tesorio SFTP file list → sync to NetSuite. The tables updated on each run are: clients, account executives, revenue sources, sale types, sale type categories, invoices, orders, and stations. This script is what the Render cron job calls.
Purpose: Generates aging report emails for AEs, the AR team, and clients.
Three email types are supported: AE aging (per Account Executive), AR aging (full view for the AR team), and AE contact request (asks AEs to update AP contact info in the CRM). Each email is generated from aging data in MongoDB using Jinja2 HTML templates. Generated emails are stored in MongoDB ( mktr__email_previews ) with a UUID batch ID, so they can be reviewed before sending. Sending is done via Mandrill (Mailchimp Transactional). The system also maintains a “do not contact” list in MongoDB per client and per AE.
Purpose: Uploads invoice PDFs (stored as base64 in MongoDB) to Tesorio’s SFTP server.
Iterates over all invoices in the mktr__invoices_pdf2 collection, skips any already uploaded (tracked in tesorio_pdf_invoice_metadata ), decodes base64, and uploads to /invoice_pdfs/<transactionNumber>.pdf on the SFTP. After uploading, generates a metadata CSV and uploads that to a separate SFTP path ( /invoice_pdf_importer/pending/ ) so Tesorio can import the files.
Purpose: Compares invoice records between Marketron and NetSuite to find discrepancies.
Accepts two CSV exports (one from Marketron’s aging/transaction report, one from NetSuite’s invoice report). Matches on invoice transaction number, sums amounts for duplicates, then categorizes each record as: Matched, MK Only (exists in Marketron but not in NetSuite), NS Only (in NetSuite but not Marketron), or Non-Invoice (journal entries, payments, etc. that should be ignored). Produces a detailed summary with dollar amounts and a downloadable result CSV.
Exposed via the Flask web app at /reconciliation .
Purpose: Lets the AR team manually map client Unified IDs to contact email addresses.
Reads and writes to the manual_contact_mapping MongoDB collection. Users can view, edit, download as CSV, and upload a replacement CSV. The endpoint at /api/manual_contact_mapping serves the data as JSON for the JS frontend. This was needed because automated Freshworks matching doesn’t always find a match for every client.
Purpose: Pulls AP contact email data from Freshworks CRM to map to Marketron client IDs.
Authenticates using a Freshworks API token. Fetches all sales accounts using the “All Accounts” filter view. Results are cached to disk (JSON with expiry timestamp) to avoid repeated API calls.
The mapping is used during email generation to find the correct AP/billing contact email for each client.
The Marketron API is paginated. The system iterates pages until no more results are returned, using a per_page setting per data type (e.g., 500 for most types, 15 for orders which have large nested structures). For incremental runs, a date filter parameter (e.g., minimumModifiedUtc ) is added to only fetch records changed since a cutoff. Data is saved to JSON files locally, then uploaded to MongoDB. For tables with has_daily_changes=True , the upload is an upsert (not a full replace).
Marketron has multiple markets (radio stations / companies), and the same client or sale type may exist under different IDs in different markets. The system generates a unified_id by normalizing the name (lowercased, stripped, de-duplicated). A cluster mapping stores the many-to-one relationship of original IDs to unified IDs. When building records for NetSuite, the system uses unified IDs so that one NetSuite customer record represents one advertiser regardless of which market they appear in.
The RESTlet receives a JSON payload with a record_type , a method , and a data_list array. On create/update, it checks whether a record with the given externalid already exists in NetSuite. If it does, it updates it; if not, it creates it. The RESTlet returns a JSON response with success/failure status for each record, which the Python side logs. For invoice line updates, a separate updatelines method updates specific lines on an existing invoice by matching on lineuniquekey .
Since Marketron scopes invoice numbers to markets, the same transaction number (e.g., INV-1234 ) can exist in multiple markets. When augmenting invoices, the system:
When syncing to NetSuite, netsuite_transactionID is used as the tranid lookup field.
The reconciliation tool does a two-pass comparison. First it loads the NetSuite CSV into a dict keyed by document number (summing amounts for duplicates). Then it iterates the Marketron CSV, looks up each transaction number in the NetSuite dict, and marks each as Matched / MK Only / NS Only. Non-invoice prefixes ( PA , JE , BA , TJ , RC ) are tagged separately. An EPSILON = 0.05 tolerance
is used for amount matching.
All secrets (API keys, DB credentials, NetSuite tokens) are in a .localenv file (not committed to git). The NETSUITE_ENV variable switches between sandbox and production NetSuite. The ENVIRONMENT variable switches file paths for things like the aging CSV storage location.
The sync scripts write timestamped log files to core/sync_logs/ on each run. Sentry SDK is initialized in data_map.py and used in the sync orchestrator to report failure cases with record-level context. The Flask app uses Python’s standard logging module at DEBUG level.
Duplicate invoice numbers across markets
Marketron numbers invoices per company (market), so two different markets can both have an invoice numbered MC-00123 . NetSuite uses transaction numbers as a unique key. This required designing a composite netsuite_transactionID field and updating all lookup logic to use it. Existing records in MongoDB and NetSuite had to be backfilled with the new field using a one-off script.
NetSuite RESTlet deployment workflow
NetSuite doesn’t allow pushing JavaScript via API — the SuiteScript file ( mrktrestlet.js ) has to be manually pasted into the Script editor in the NetSuite UI. Any change to the RESTlet required a manual deploy step, which slowed down iteration. Sandbox and production scripts have different script IDs and the file needed to be kept in sync between both.
Data unification complexity
The same client, sale type, or revenue source could appear under 30+ different IDs across markets. Building a reliable clustering/unification step that worked consistently across all data types required careful normalization (case-insensitive name matching, handling empty/null names) and manual overrides for edge cases.
Large invoice payloads causing timeout issues
Invoice records in Marketron include nested order line arrays, which can be large. The initial pagination for orders used 500 per page, which caused HTTP timeouts. This was tuned down to 15 per page for orders to keep requests within acceptable time limits.
Freshworks contact matching gaps
Marketron client names don’t always match the company name in Freshworks CRM exactly, so automated mapping often left clients without an AP email address. This is why the manual contact mapping UI was built — to let the AR team fill in gaps that the automated Freshworks lookup couldn’t handle.
pysftp host key issues
The Tesorio SFTP server’s host key caused connection failures in some environments. This was handled by disabling host key checking ( cnopts.hostkeys = None ), which was an acceptable trade-off for an internal transfer to a known server.
Incremental vs. full data pulls
For some Marketron endpoints, the date filter parameters didn’t always return recently modified records reliably. The workaround was to run wider date windows (7-day or 30-day lookback) rather than strict incremental pulls.
| Item | Detail |
|---|---|
| Language version | Python 3.12 |
| Web server | Gunicorn (multi-worker) |
| Hosting | Render (PaaS) — “Pro” plan, auto-deploy on push |
| Database | MongoDB Atlas (cloud-hosted, accessed via SRV connection string) |
| File storage | Persistent disk at /storage/ on Render (for aging CSV uploads) |
| Config | .localenv file locally; environment variables on Render |
| Deploy command | pip install -r requirements.txt |
| Start command | gunicorn app:app |
| Cron / scheduler | Render cron job triggers the data pipeline on a schedule (run_render_job.py) |
| NetSuite environments | Sandbox (9639742-sb1) and Production (9639742), controlled by NETSUITE_ENV |
| Error monitoring | Sentry DSN configured via SENTRY_DSK env variable |
This project built a working integration between Marketron (traffic/order management) and NetSuite (ERP/accounting) for BBGI. The system pulls advertising data across multiple markets daily, stores it in MongoDB, processes it, and syncs it to NetSuite as proper financial records.
The main complexity was around data unification (same entity appearing under many IDs across markets), duplicate transaction number handling, and the manual RESTlet deployment workflow inside NetSuite. These were all solved with a combination of derived fields, composite keys, and environment-based configuration.
On top of the pipeline, an internal Flask tool was delivered that gives the AR team a reconciliation view, contact mapping management, and automated aging emails via Mandrill. Invoice PDFs are also synced to Tesorio automatically via SFTP as part of the daily run.
The codebase was also refactored from a flat script layout into a structured src/integrations/package organization to improve maintainability and make each integration boundary explicit.