Two currencies of concession, and only one of them can be haggled over
Nakodo's business campaigns point the other way round from its creator campaigns. A creator campaign asks a creator to make something and agrees a fee to pay them. A business campaign writes to businesses that might buy
Nakodo's business campaigns point the other way round from its creator campaigns. A creator campaign asks a creator to make something and agrees a fee to pay them. A business campaign writes to businesses that might buy the brand's product, so the thing being agreed is not money going out, it is what the business pays. The Business plan says it on the page: Nakodo agrees fees and discounts within your limits.
Two things can be given, and they are different shapes:
- a percentage off the brand's own prices, for a period;
- a longer free period before paying starts.
I wrote the discount ladder first and assumed the free period would be the same function with different units. It is not, and the difference is the only genuinely interesting thing in src/lib/outreach/concession.ts.
The discount ladder
export function nextDiscount(o: { offered: number; asked: number; limit: number; counters: number; final: boolean }): ConcessionMove {
const limit = Math.max(o.limit, o.offered);
const { offered, asked } = o;
if (asked <= offered) return { type: "accept", amount: offered };
if (asked <= limit) {
if (o.counters > 0) return { type: "accept", amount: asked };
const amount = roundDown(asked * COUNTER_SHARE);
return amount <= offered || asked - amount < MIN_HAGGLE
? { type: "accept", amount: asked }
: { type: "counter", amount, final: false };
}
if (o.final || o.counters >= MAX_COUNTERS) return { type: "ask_brand", amount: asked };
return { type: "counter", amount: limit, final: true };
}
Three moves exist: accept, counter and ask_brand. Four lines deserve comment.
Asking for no more than what is on the table is a yes. If they have been offered 20% and they ask for 15%, that is agreement with a worse memory, and arguing with it would be absurd. The move is accept at the amount already offered, not at the smaller one they named.
One middle offer, then their number. counters > 0 means Nakodo has already made an offer in between, so the second time around their ask is simply agreed if it is still inside the limit. There is exactly one haggle in the system.
MIN_HAGGLE = 2. Two percentage points. If the computed middle offer is within that of their ask, the middle offer is dropped and the ask is accepted, because countering 23% with 22% reads as mean rather than careful, and the two points were never the point.
Math.max(o.limit, o.offered). A limit can never retract an offer already made. If the brand lowers its maximum discount while a conversation is open, the business still has whatever was already put in writing.
I am leaving COUNTER_SHARE out, as with the creator fee ladder, for the reason that an exact share would let anyone read the ladder backwards from a single counter offer and know precisely what the limit was. The shape is public; the constant is not.
The free period has no middle
export function nextFreePeriod(o: { asked: number; limit: number }): ConcessionMove {
return { type: o.asked <= o.limit ? "accept" : "ask_brand", amount: o.asked };
}
Two outcomes. Inside the brand's limit, agree. Outside it, hand it to the brand. No counter, ever.
The comment in the file explains it: how long the first email already offered is not known here, so a shorter period could undercut the offer they already have. That is the asymmetry. A discount conversation starts from zero, so any counter is an improvement on where the business stood. A free period does not: software outreach usually opens by offering an extended trial, and that offer is in the first email, which this function cannot see. Counter a request for 90 days with 60, and you may have just taken 30 days off an offer the business was already holding. The same mistake in the discount ladder is impossible because offered is tracked and asked <= offered short circuits it.
The product answer for which of the two to offer in the first place is on our page for SaaS brands: for most software an extended trial beats a discount, because a discount on a price nobody has tested yet means very little.
It never says no
A creator negotiation can end in a refusal, because a creator who costs more than the brand will pay is simply too expensive. A business negotiation cannot, and the comment says so plainly: once Nakodo has offered the most the brand allows, anything further is the brand's call, because the brand may still want the sale. A 60% discount might be terrible or might be the deal of the quarter, and nothing in the code knows which.
So "past the limit" resolves to a question, not a rejection, and the question says which kind of limit was hit:
const question = most
? `They asked for ${askedText}. You've agreed as many deals as this campaign allows, so it's yours to decide.`
: kind === "discount"
? `They asked for ${askedText}. You haven't set the most you'd discount, so it's yours to decide.`
: `They asked for ${askedText}. You haven't set the longest free period you'd give, so it's yours to decide.`;
Three sentences for one null, because the null means three different things. The brand never set a maximum. The brand set one and the campaign has used up its allowance of deals. Or the ask is simply bigger than the maximum. "Nakodo needs your input" would be true in all three cases and useful in none.
concessionLimit is where those collapse:
export function concessionLimit(o: { most: number | null | undefined; max: number; dealsLeft: number | null; offered: number }): number | null {
if (!o.most) return null;
if (o.dealsLeft !== null && o.dealsLeft <= 0) return null;
return Math.max(o.offered, Math.min(o.most, o.max));
}
dealsLeft comes from counting how many businesses this campaign has already agreed something with, which is a query rather than a counter column:
return rows.filter((r) => r.id !== except && r.offer?.agreement?.agreed).length;
Introduced conversations only, this one excluded, and an introduction that was taken back stops counting automatically, because the thread's status moves away from handed_off and it drops out of the query. A column incremented on agreement would have needed a decrement on reopen, and that decrement is exactly the sort of thing that gets forgotten.
Two app-wide ceilings and a parser
export const DEFAULT_DISCOUNT = 20;
export const MAX_DISCOUNT = 90; // more than this isn't a discount, it's free
export const MAX_FREE_DAYS = 365;
The brand's own maximum is the real limit; these are the walls the settings live inside. 20% is what the setting starts at, because most brands can carry a fifth off for a first year.
Everything arriving from a parsed reply goes through one helper on the way in:
const whole = (n: number | null | undefined, max: number): number | null => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
const v = Math.round(n);
return v > 0 ? Math.min(v, max) : null;
};
Not a number, not finite, zero or negative, all become null, and null routes to the brand rather than to a default. "40.5% off" becomes 41. "900% off" becomes 90 and then meets the brand's own maximum below that. The one thing this function must never do is return a plausible number for nonsense input.
Two more refusals sit above the ladder, and both are judgement calls rather than parse failures. Asking for a discount and a longer free period gets Two things at once is yours to weigh up, because weighing one against the other is a pricing decision. Asking to pay less in money, with no percentage, also goes to the brand, because that needs the brand's own numbers rather than a share of them.
And switching currency restarts the count:
const before = ctx.offer.agreement?.kind === kind ? ctx.offer.agreement : null;
If a business asked for a discount, got a counter, and then asked for a free period instead, the counter history does not carry over. Otherwise the move counter from one negotiation would quietly limit a different one.
Where it is stored, and what the model is told
The whole agreement is one object inside the offer jsonb column that threads already had, so none of this needed a migration, and offerAfter is a pure function from a move to the next state:
if (move.type === "accept") return { ...offer, agreement: { ...agreement, offered: move.amount, agreed: move.amount, waiting: null } };
if (move.type === "counter") return { ...offer, agreement: { ...agreement, offered: move.amount, counters: agreement.counters + 1, final: move.final } };
return { ...offer, agreement: { ...agreement, waiting: move.amount } };
agreed is what they said yes to. waiting is an ask the brand was asked to agree. Both nullable, never both set, and the distinction is what stops a question to the brand being read later as a deal.
The model's role is writing, and the input it gets contains the move and the amount to offer. It does not contain the limit, the brand's maximum, how many deals are left, or anything about other businesses. Then the output is checked against that input for numbers that were never in it, and text that quotes an amount it was not given is thrown away in favour of a plain sentence assembled in code. An email is allowed to be duller than the model would like. It is not allowed to invent a discount.
That is the design: the decision in code, the wording in the model, and the limits never leaving the server. The plan that includes it is on pricing, and what the whole loop does from reading a website to making an introduction is on how it works.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.