The
Accountant
That Codes

Meet Rev Rick: a DIY revenue agent from Closed Won to SuiteBilling

Francisco Meyo · 10 min read

A deal closes. Sales rings the gong. And somewhere in revenue accounting, someone opens the Salesforce opportunity, goes hunting for the signed contract, reads it, and retypes the whole thing into NetSuite SuiteBilling. Billing account, price plan, subscription — one field at a time.

There are plenty of products that automate a slice of that. Some start from a PDF you upload. Others sync Salesforce fields into a sales order. Both skip the part that matters: the executed contract is the source of truth, and it lives in your contract management system. Not in an upload folder, and not in the CRM fields sales filled in from memory.

So we built the whole thing ourselves. Meet Rev Rick — an end-to-end, DIY revenue agent. Here's what one deal looks like:

  1. It starts in Salesforce, with the queue of Closed Won opportunities.
  2. It goes to Ironclad, our contract management system, and pulls the executed agreements on its own.
  3. Claude reads them, decides what kind of deal this is, and extracts the commercial terms.
  4. Python reconciles those terms against the opportunity, rule by rule.
  5. It proposes the SuiteBilling treatment — a new subscription, a change order, or a termination plus a new subscription — and books it once a person signs off.

That last step is the one I couldn't find anywhere. As far as I know, nothing out there lets you put an agent in front of SuiteBilling — not even NetSuite itself.

The full code is open source in the theaccountantthatcodes GitHub repo, demo mode included. Let's walk through how it works.

1. It starts where the contract actually lives

Rev Rick never asks you for a PDF. It follows the opportunity to the contract system and downloads the signed copy itself.

Ironclad's Salesforce package keeps a contract record linked to each opportunity, so one SOQL query gets us the ids:

ic_query = (
    "SELECT Id, Contract_ID__c, Opportunity__c "
    "FROM ironclad__Ironclad_Contract__c "
    f"WHERE Opportunity__c IN ('{ids_str}')"
)

With the ids in hand, Ironclad's API hands over the executed PDF:

def download_signed_copy_bytes(record, access_token, user_email):
    url = _api(f"/public/api/v1/records/{record['id']}/attachments/signedCopy")
    headers = {
        "Authorization": f"Bearer {access_token}",
        "x-as-user-email": user_email,
        "Accept": "application/pdf",
    }
    response = requests.get(url, headers=headers)
    return response.content if response.status_code == 200 else None

That's the signed copy. Not a draft, not the version someone saved to their desktop. Every MSA, SOW and Order Form linked to the deal comes along for the ride.

The agent reads what legal executed, and nobody has to go find it.

2. Claude reads the documents and names the deal

Before it extracts a single number, Claude decides what kind of deal this is: Initial, Pilot Conversion, Expansion, Conversion, Renewal or Credit. That one call picks the SuiteBilling transaction downstream, so the classifier prompt is where most of the accounting judgment lives. It's worth reading.

It opens with the ground rule. The Salesforce record type is an input, not an answer:

CORE PRINCIPLE
The classification must be your own conclusion from reading the
documents. The signals below are hints and priors, not rules; do
not apply them as a lookup table. The Salesforce record type is an
input that may be wrong or stale. It is exactly what this review
exists to verify. Never classify on the record type alone.

Then every deal type gets a definition and the signals that usually travel with it. This is the playbook a revenue accountant carries in their head, written down:

TYPE DEFINITIONS AND TYPICAL SIGNALS
 
Initial: first agreement with a customer, either a pilot or a
direct long-term deal.
- Record type is usually Initial.
- Documents usually include an SOW setting out the Service Term.
- An Order Form and/or MSA may also be present.
 
Pilot Conversion: existing customer moves from a pilot or trial
into a long-term deal.
- Record type is usually Pilot Conversion.
- Documents are usually an Order Form only, since the SOW was
  signed for the pilot.
- Service Term is normally governed by the existing SOW, though an
  amendment to the governing documents is also common.
 
Expansion: additional scope added under an existing agreement,
typically additional seats or a Tier change. The Service Term
should match the original order. If new products are added and a
new Service Term is defined, classify as Initial.
- Record type is usually Expansion.
- Documents are usually an Order Form only.
- No new Service Term; the existing term continues to govern.
 
Conversion: existing customer moves onto new or restructured
terms. Most commonly a shift from subscription-based fees to an
enterprise fee; also a change in fees and Service Term (often a
fee reduction paired with a term extension).
- Record type is usually Expansion; there is no Conversion record
  type in Salesforce.
- Documents usually include a new SOW establishing a new Service
  Term, possibly with an Order Form.
 
Renewal: continuation of an expiring agreement.
- Record type is usually Renewal.
- Documents usually include a new SOW with a new Service Term,
  though it may be an Order Form only.
- Often references a prior contract by name, date, or effective
  date.
 
Credit: a credit, incentive or grant with no new product scope.
- Record type is Credit Memo.

Notice the wording: "usually", "typically", "may". These are priors, not rules. Turn them into a lookup table and you've just rebuilt the record type.

Then it names the hard case out loud:

THE HARD CASE: EXPANSION VS. CONVERSION
Both typically carry record type Expansion, so record type
provides no separation. The discriminating question: do these
documents establish a new Service Term, or add scope under an
existing one?
- New SOW with a new Service Term: lean Conversion.
- Order Form only, existing term continues: lean Expansion.
 
Conversion vs. Renewal is a related judgment call, since both
establish a new Service Term. A Renewal continues substantially
the same arrangement past its expiry; a Conversion restructures
the commercial arrangement. A reference to a prior contract
appears in both and does not by itself decide it.

That hard case is the whole game. An expansion is a change order on a subscription that's already running. A conversion terminates it and starts a new one. Same record type in Salesforce, completely different transactions in SuiteBilling — and the only thing that separates them is whether the paper sets a new Service Term.

And it's told when to stop guessing:

WHEN TO ESCALATE RATHER THAN GUESS
Set contract_type to Unknown, needs_human_review to true, and
explain why in review_reason when:
- The documents are insufficient to tell (for example an Order
  Form only that references an SOW not provided).
- The document evidence contradicts the Salesforce record type.
- The documents point to more than one type with roughly equal
  support.
- Documents are unreadable, truncated, empty, or clearly not the
  executed version.
Disagreement with Salesforce is a valid and useful output, not a
failure. Report it rather than reconciling to the record type.

That last line earns its place. Without it, the easy answer is to agree with the record type you were handed.

Code decides what happens with the answer. Claude responds with structured output against a JSON schema: type, confidence, rationale, evidence quotes, the Service Term and any prior-contract reference. Python then checks it against Salesforce and decides whether Rev Rick can keep going on its own:

# Which conclusions count as agreeing with each Salesforce record type.
# Expansion accepts Initial: new products with their own Service Term
# are classified Initial.
EXPECTED_TYPES_BY_RECORD_TYPE = {
    "Initial": {"Initial"},
    "Pilot Conversion": {"Pilot Conversion"},
    "Expansion": {"Expansion", "Conversion", "Initial"},
    "Renewal": {"Renewal"},
    "Credit Memo": {"Credit"},
}
 
def _classification_is_clean(c: dict) -> bool:
    return (config.AUTO_ADVANCE_CLEAN_CLASSIFICATION
            and c["needs_human_review"] is False
            and c["agrees_with_salesforce"] is True
            and c["confidence"] != "low")

A clean classification moves on by itself. Anything else stops for a person, with every evidence quote one click away from its page in the PDF. A second call then extracts the terms the same way — dates, term, billing frequency, payment terms, line items, tiers, ramps.

3. From the read to a SuiteBilling treatment

Claude's read becomes a proposed NetSuite transaction through plain Python. No model decides what gets booked.

First, the reconciliation: one row per rule, each with a tolerance and a severity. Dates match exactly or within a day, amounts within 1% or $1. In the demo data, Salesforce says 120 users and the SOW says 100:

[open] line[Subscription License].quantity: contract=100.0 | salesforce=120.0  (exact)

At $180 per user per month, that's $43,200 of ARR living in the CRM and nowhere in the contract. A rule caught it, with the contract quote attached.

Then the treatment. The deal type picks the SuiteBilling action:

if contract_type in ("Initial", "Pilot Conversion", "Renewal"):
    plan.update(action="new_subscription", ...)
elif contract_type == "Expansion":
    plan.update(action="change_order", reason="MODIFY_PRICING on the existing subscription")
elif contract_type == "Conversion":
    plan.update(action="terminate_and_new", ...)

From there, netsuite/builders.py produces the exact payloads: billing accounts, tiered and volume price plans, subscriptions with one price interval per ramp period, and change orders. Here's the one for an expansion:

def build_modify_pricing_change_order(subscription, line_number, effective_date, quantity_new,
                                      recurring_amount_new, price_plan_new_id, sf_opportunity_id, memo):
    line = {"sequence": str(line_number), "apply": True,
            "quantityNew": quantity_new, "recurringAmountNew": recurring_amount_new}
    if price_plan_new_id is not None:
        line["pricePlanNew"] = {"id": str(price_plan_new_id)}
    return _change_order("MODIFY_PRICING", "UPSELL_DOWNSELL", subscription,
                         effective_date, sf_opportunity_id, memo, [line])

If you've ever wrestled the SuiteBilling REST API, you know lines like these are earned. NetSuite derives the recurring amount from the price plan, so the number you send has to match what the plan yields — or you get a 400. Re-send a price plan that's already on the line and NetSuite rejects that too. Those lessons live in the builders as comments, so you only pay for them once.

4. It stops where judgment starts

Rev Rick runs on its own while a stage is clean and stops when a named condition holds. I call it checkpoint autonomy.

StageRev Rick stops when
DocumentsNo contract is linked, or a PDF has no text layer
ClassifyConfidence is low, or the type disagrees with Salesforce
ReconcileAny error row is open
MatchSeveral candidate subscriptions, weak matches only, or none
Payload review and pushAlways

Every override costs a note, and every note lands in the session log next to the agent's own steps. That log is your audit trail.

Corrections never touch the payload either. You fix the terms — inline, or by telling Rev Rick in plain words what's off — and code rebuilds the payload. Nobody edits JSON.

And when it's time to book, exactly one function writes to NetSuite. It first proves the payload is the one you reviewed, for the environment you picked:

def payload_hash(env: str, steps: list) -> str:
    body = json.dumps({"env": env, "steps": steps}, sort_keys=True, default=str)
    return hashlib.sha256(body.encode()).hexdigest()
 
expected = payload_hash(env, built["steps"])
if confirmed_payload_hash != expected:
    raise PushRefused("The confirmed payload does not match the current payload for this environment. Review it again.")

You choose SANDBOX or PROD on every push, with nothing preselected, and PROD wants the word PROD typed out. A conversion terminates a live subscription, so it has to pass through the sandbox first. And the chat has no /push command. On purpose.

Want to give it a shot?

The repo is a sanitized template with a demo mode, so you can run the whole flow without a single credential:

poetry install
REV_RICK_DEMO=1 poetry run python main.py   # http://127.0.0.1:8080

Five deals are waiting in the queue, each built to hit a different checkpoint. The customers include Vandelay Industries, Pendant Publishing and Schrute Farms, all buying from Dunder Mifflin. I regret nothing.

The contract system sits behind one small module, sources/ironclad.py, so pointing Rev Rick at a different one is a contained change. And the build spec that produced the app ships as CLAUDE.md, rewritten as a template you can hand to a coding agent for your own version.

The full code — every module, the five demo deals and the test suite — is in the theaccountantthatcodes GitHub repo. Clone it, run the demo, and point it at your own systems.

FAQ

Q: Is there an AI agent for NetSuite SuiteBilling? Not off the shelf, as far as I know. Rev Rick is an open, DIY template: Claude reads the executed contract, Python builds the subscriptions and change orders through NetSuite's REST API, and a person approves the exact payload before anything is written.

Q: How is this different from a Salesforce-to-NetSuite integration? A connector maps CRM fields to NetSuite records. Rev Rick reads the executed agreement from the contract management system, treats it as the source of truth, reconciles the opportunity against it, and picks the SuiteBilling treatment from what the documents say.

Q: Do I need Ironclad to use it? No. Demo mode needs no systems at all, you can upload PDFs at the documents stage, and the Ironclad calls live in one module you can swap for your own contract system.


If your agent starts from an upload, someone still has to go find the contract. If it starts from CRM fields, it books what sales typed. If it starts from the executed agreement and ends in SuiteBilling, it books the deal that was signed.

As a CPA and Controller, I built Rev Rick for a real order-to-cash process. The repo is a sanitized template: the architecture and the controls are the real thing, and the products, ids and customers are not.

Last updated: October 2026.

Subscribe to the newsletter

Python, APIs, and AI automation for finance teams. No spam.