From "Deal Won" to Actual Profit: Notes from a CRM/ERP Sync
Pipedrive knows a deal closed. It doesn't know whether the job made money. How I snapshot profit at the moment a deal closes, decide which system owns each field, and why an unused default gem broke a deploy.
For the past while I've been maintaining a Rails app that sits between two systems a turf and landscaping company runs on: Ostendo, their ERP, and Pipedrive, their CRM. Customers in Ostendo become Organizations in Pipedrive. Jobs become Deals. When a salesperson marks a deal won or lost, that status gets written back to Ostendo.
It's a sync app. Most of the work is unglamorous: retries, deduplication, figuring out why the same deal keeps re-syncing every five minutes. But a few changes this summer are worth writing down.
"Won" is not the same as "made money"
A CRM is very good at telling you that a deal closed. It's much worse at telling you whether that deal was any good. Pipedrive knows the deal value. It doesn't know what the job cost to deliver. Ostendo does.
The Ostendo job payload already carried what I needed: an original order amount, an original order cost, and a margin percentage. So the first step was a few plain readers on Job:
def ostendo_gross_profit
rev = ostendo_revenue
cost = ostendo_cost
return nil unless rev.present? && cost.present?
rev - cost
end
The catch is that ostendo_data gets overwritten on every import. Costs change after a job closes. If you want to answer "how much profit did we book in June?", you can't compute it live from whatever Ostendo says today. You need to freeze the numbers at the moment the deal closed.
Snapshot the moment, not the state
That's what JobOutcome is. One row per job, written once, when the job's status flips to won or lost:
def record_outcome_if_closed(job, old_status:, new_status:, ostendo_data: nil)
return unless new_status.in?(%w[won lost])
return if old_status == new_status
return if job.job_outcome.present?
outcome_date = extract_outcome_date(ostendo_data) || Date.current
JobOutcome.record_outcome(job, outcome: new_status, outcome_date: outcome_date)
rescue => e
Rails.logger.error "[JobSync] Failed to record outcome for job #{job.id}: #{e.message}"
end
A few decisions in there are deliberate.
First closure wins. If a job gets reopened and closed again, the original outcome stands. That's enforced three times: the early return above, a uniqueness validation on job_id, and a rescue of RecordNotUnique in the factory method. Belt, braces, and a second belt. When sync jobs run every five minutes through the working day, I'd rather be boring about it.
Two timestamps, not one. outcome_date is when the deal actually closed, pulled from Ostendo's close date, falling back to its finished date, then its last-modified date. recorded_at is when my app noticed. Those are different things, and when an import has been paused for a weekend, the gap matters. Reporting groups by outcome_date.
Recording an outcome never breaks the import. The whole method is wrapped in a rescue that logs and moves on. Profit reporting is nice to have. Customers and jobs syncing correctly is the job. I didn't want a nil in a cost field to stall the pipeline.
The row also copies the salesperson and customer name at close time rather than relying on the associations. People move territories. Customers get renamed. A snapshot should still read correctly a year later.
The hook lives in the Ostendo import path, in the three places where a job gets saved. That's where status transitions are observed, so that's where outcomes get recorded.
Reporting is just scopes
I haven't built a dashboard for this yet, and I didn't need to in order to make the data useful. The model carries scopes and a few class methods, and they compose:
JobOutcome.this_month.total_profit
JobOutcome.last_90_days.win_rate
JobOutcome.in_month(2026, 6).profit_by_salesperson
profit_by_day and profit_by_month return arrays of hashes shaped for a chart. The monthly grouping uses SQLite's strftime('%Y-%m', outcome_date), which is the kind of thing you'd have to change if you ever moved databases. I'm fine with that. The migration adds composite indexes on [outcome, outcome_date] and [outcome, salesperson] because those are the only two questions anyone is going to ask.
The takeaway: if a number changes after the event you care about, store it at the event. Don't compute historical reports from current state.
Who owns which field
A related thread ran through the spring and summer: deciding which system is the source of truth for each field, and then enforcing it in code.
Back in May I tracked down a bug where deals marked won in Pipedrive were getting reset to open. The cause was that every Ostendo import rewrote the status. The fix was a rule: Ostendo sets status when the deal is created, and after that Pipedrive owns it. Mismatches get flagged in the admin UI instead of overwritten.
In August the same rule got applied to two more fields. Deal owner is now only assigned at creation, so manual reassignments in Pipedrive stick. Expected close date is never pushed from Ostendo at all, even if someone maps it in the settings screen:
EXCLUDED_PIPEDRIVE_FIELD_KEYS = [
"expected_close_date"
].freeze
It gets stripped from the payload right before every create and update call, and skipped during custom-field mapping. That's two checks on purpose. The mapping UI is configurable by admins, and a hard-coded exclusion is what stops a well-meaning config change from overwriting the sales team's forecasts.
In a two-way sync, every field needs exactly one owner, and the code should say which.
The gem nobody used
Now the maintenance story, which is short.
The pre-push hook on this repo runs Brakeman, bundler-audit, and RuboCop. In August bundler-audit started failing. I bumped Rails to a patch release and updated a handful of other gems (nokogiri, mail, loofah, and friends). The audit went green.
Then the deploy wouldn't boot.
The Rails security release added a guard in Active Storage: it refuses to boot if ruby-vips is loaded against an older libvips. The base Docker image ships an older libvips. And ruby-vips was only there because image_processing was in the Gemfile, left over from the default Rails template.
I checked. No attachments, no variants, no variant_processor config anywhere in the app. Nothing had ever used it. The fix was deleting three lines from the Gemfile.
Upgrading the base image would also have worked. But the gem was dead weight, and removing it cost nothing and took one commit.
Default gems are still dependencies. They get security patches, pull in native libraries, and can break your boot. If rails new added it and you never used it, take it out before a CVE finds it for you.
What's next
The profit data is being collected now, which was the point of shipping the model first. A reporting screen can come later, built on scopes that already work in the console. That's usually the order I like: get the data right, then decide how to show it.
FREE Shopify Product Migration
Moving to Shopify? We'll migrate your product catalog for free. New stores only.