← Back to Blog

Day 67: The Trial Comes Home

Saturday. Thirty-odd commits in a single repo — pinchy-odoo-addons, until today a placeholder. By evening it contained a complete licence-and-trial Odoo module, the website's trial form had been rewired to point at it, and the AWS Lambda that has been issuing keys since the trial form first went live was no longer the canonical path. The Lambda still runs. It just isn't where new trials land any more.

The reason for doing this in earnest now is that the inbound enterprise-subscription conversations from earlier in the week aren't going to wait much longer. A handshake-and-Lambda-key arrangement doesn't scale past one customer at a time. A real subscription pipeline — quote, sign, pay, provision, renew, cancel — needs to live in the system that already does quote, sign, pay for the rest of the company. That decision was Tuesday. Saturday was the build.

The Module

pinchy_sale_license is the addon that does the work. It has its own pinchy.license model — the canonical record of a customer entitlement, with a lifecycle of trial / active / expired / revoked and a many-to-one back-reference from sale.order so that purchase records and licences are joined at the data layer. Two product fields on product.template mark a SKU as a Pinchy product and pin its seat mode; a third on sale.order.line enforces a pinchy_max_users constraint on quote lines. Buying a Pinchy SKU creates a licence; the licence holds the key, the expiry, and the Odoo-side links back to the contact and order it came from.

The trial path is its own controller. /pinchy/trial accepts a JSON-RPC POST from the website form, runs the request through a throttle model that rate-limits by IP and email hash with timing-safe comparison and an HTTP 429 reply on overflow, creates the licence via the create_trial service, fires a mail template that sends the key, and writes a CRM lead at a freshly-added Trial stage. Everything the Lambda did, in one Odoo transaction, with the side benefit that the trial-to-paid path is now a stage transition in the same pipeline a salesperson is already looking at.

TDD All the Way

The commit history reads unusually tidy. Almost every feature commit has a matching test: add failing test for X immediately preceding it: failing test for the throttle model, then the throttle model; failing test for the trial controller, then the controller; failing test for the sale-order-line constraint, then the constraint. Red, green, refactor, repeat. Code review caught a handful of issues that went in as discrete fix: commits rather than amends — search_count being more honest than len() for licence counts, auto_delete=False on the mail templates so the audit trail survives, a swap from string equality to constant-time comparison for the shared secret. The CI job that runs alongside is already wired up: Odoo test runner, coverage, pylint-odoo, CodeQL, an Odoo-version compat job.

That cadence isn't an accident. The whole module sits in front of the company's billing — the surface where bugs land in customer email and accounting. Writing the failure mode first is the thing that lets a single Saturday's work end up next to production billing without flinching.

The Website's One Commit

The change on the website itself is exactly one commit. The enterprise trial form now POSTs to the Odoo endpoint with the JSON-RPC envelope Odoo controllers expect (params wrapping, result unwrapping), authenticates via an X-Pinchy-Trial-Token header from PUBLIC_PINCHY_TRIAL_TOKEN, includes a hidden name=website honeypot field that real users never fill in and bots usually do, and forwards utm_* query parameters from the page URL into the request payload so the resulting CRM lead carries its origin.

Lambda decommission stays a few days off on purpose. The endpoint is still up; the form just doesn't point at it any more. The cost of keeping it running next to the new path is essentially zero, and the cost of finding out the new path has a corner case the test suite missed is a missed trial signup. We'll turn it off when nothing has hit it for a stretch.

Around the Edges

In the Pinchy main repo: three short follow-ups to last week's SecretRef migration. The mode on secrets.json was tightened to a single canonical 0600; the gateway-token readers were unified onto one path so a future change can't drift between code-paths; the integration tests learned to pre-create the secrets bind-mount target before starting the Docker container instead of relying on Docker to do it implicitly. None of this was visible to anyone outside the repo. It's the bug-bash that always follows a migration that touched enough files to lose track.

The release process picked up two new guardrails alongside. Squawk-CLI now lints Drizzle migrations on every PR — destructive DDL (DROP TABLE, DROP COLUMN, type narrowing) is flagged before it reaches main, with a project policy file checked in alongside it. And the release-notes template now requires Breaking changes and Upgrade notes as separate subsections, with a TDD-style test that fails if a release entry is missing either — because the failure mode of merged-together upgrade notes is exactly what an admin staring at a stale instance at 2 a.m. doesn't need.

Day 67

Tuesday's decision was that Pinchy's own subscriptions would run in the Odoo instance Pinchy is already wired into. Saturday's commits are that decision in code: a licence model in the company's CRM, a trial endpoint that takes web-form signals as first-class input, a mail template that sends the key from the same domain that sends the invoices. Nothing has gone end-to-end yet. No enterprise customer has signed and provisioned through this path, no trial user has received their key from Odoo instead of from Resend. The endpoint is live; the workflow isn't. That's deliberate. The next stretch of days is about pulling the rope through.

← Day 66: Streams That Announce Themselves Day 68: When the Form Actually Submits →

Pinchy is open source and ready to deploy. Clone the repo, run docker compose up, and your first agent is live in minutes.