Building Proposly: AI Proposals, Dual Payments, and PDF Generation
Building Proposly: AI Proposals, Dual Payments, and PDF Generation
Proposly is a proposal management platform built for freelancers. You fill in the project details — scope, deliverables, timeline, budget — and it generates a full proposal draft. You can edit it inline with AI actions (rewrite, shorten, change tone), then export it as a styled PDF complete with terms and conditions, or send it directly to the client by email. There's a Pro subscription behind the advanced features.
The backend is NestJS, the frontend is Next.js with TipTap for the rich text editor, and the AI layer uses Claude. Here are the parts that were most interesting to build.
Choosing Which Claude Model to Use Where
The AI features split cleanly into two categories: generation (expensive, quality-critical) and editing (cheap, latency-sensitive). I used two different models:
- Claude Opus 4.5 — full proposal generation from a structured brief
- Claude Haiku 4.5 — inline editing actions: rewrite, shorten, formalize, fix grammar
Generation is a deliberate, one-shot action that the user waits on. Quality matters more than speed. Editing happens in the TipTap editor — the user selects a sentence, picks an action, and expects a fast response. Haiku is a fraction of the cost and returns in well under a second.
This two-tier pattern — flagship model for high-stakes generation, fast model for real-time editing — is worth keeping in mind whenever you're building AI into a product with multiple interaction surfaces.
Inline AI Actions in the Editor
The TipTap editor supports four inline actions on selected text: rewrite, shorten, formal, fix. Each one hits /api/ai/rewrite with the selection and the action type. The backend maps the action to a directive and sends it to Haiku:
const directives = {
rewrite: 'Rewrite this text to be more professional, compelling, and clear.',
shorten: 'Make this text shorter and more concise while keeping all key information.',
formal: 'Rewrite this text in a more formal, professional tone.',
fix: 'Fix any grammar, spelling, punctuation, and awkward phrasing.',
};
const message = await this.client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 500,
messages: [{ role: 'user', content: `${directives[instruction]}\n\nTEXT:\n${text}` }],
});
Simple, but the constraint of max_tokens: 500 is intentional — inline edits should replace a sentence or paragraph, not balloon into a multi-page rewrite.
Dual Payment Providers
Stripe is the default for international users. Paystack handles Nigeria specifically — the checkout UX is significantly better for Nigerian cards and bank transfers than Stripe's.
Both providers expose the same interface to the frontend: POST /billing/checkout for Stripe, POST /billing/paystack/checkout for Paystack. Both return { url }. The frontend redirects the user to that URL and gets them back on the billing/success callback.
The webhook handlers mirror each other in structure. Stripe's side:
// Must run BEFORE body-parser — constructEvent needs raw bytes
event = this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret);
switch (event.type) {
case 'checkout.session.completed':
await this.usersRepository.update(userId, {
plan: UserPlan.PRO,
stripeCustomerId: session.customer,
});
break;
case 'customer.subscription.deleted':
await this.usersRepository.update(
{ stripeCustomerId: subscription.customer },
{ plan: UserPlan.FREE },
);
break;
}
Paystack's side handles charge.success and subscription.disable events with the same plan upgrade/downgrade logic — just different field names on the payload.
One detail that tripped me up with Stripe: constructEvent uses HMAC-SHA256 on the raw request body bytes. Any JSON parsing before that call — even Express's bodyParser — mutates whitespace and breaks the signature. The webhook route needs rawBody: true in NestJS, and the raw buffer has to be passed directly to constructEvent. Get this wrong and signature verification fails every time.
Markdown to PDF via Puppeteer
Proposals are stored and edited as markdown. When a user exports to PDF, the pipeline is: markdown → HTML → headless Chrome → PDF.
const htmlBody = await marked(markdown);
browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.setContent(fullHtml, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '48px', right: '56px', bottom: '48px', left: '56px' },
});
The fullHtml includes the styled proposal body plus two auto-generated sections that get appended every time: a Terms & Conditions block (confidentiality, IP transfer, liability limitation, governing law) and a signature section with two sign-off blocks.
The freelancer fills in their governing law jurisdiction once in their profile. The PDF picks it up from there — "governed by the laws of ${law}". This means every exported proposal already has basic legal coverage without the freelancer having to draft anything.
Puppeteer on a server is heavyweight — the binary is large and cold starts are slow. For low-volume usage it's fine. At scale I'd move PDF generation to a dedicated queue and return a download link rather than blocking the HTTP request.
Share Tokens
Clients don't need an account to view a proposal. When a freelancer shares a proposal, it generates a UUID token stored on the proposal record. The public URL is /p/{shareToken} — no authentication required.
async generateShareToken(userId: string, id: string) {
const proposal = await this.findOne(userId, id);
if (!proposal.shareToken) {
proposal.shareToken = randomUUID();
await this.proposalsRepository.save(proposal);
}
return { shareUrl: `${frontendUrl}/p/${proposal.shareToken}` };
}
The token is generated on first request and reused on subsequent calls — the same link stays valid until the proposal is deleted. There's no expiry on purpose: you don't want a live proposal link to stop working mid-negotiation.
What I'd Do Differently
- Streaming for generation. Waiting 5-10 seconds for a full Opus response with nothing happening on screen is a poor UX. Streaming the content into the editor token by token would make the generation feel fast even when it isn't.
- Queue PDF export. Blocking the HTTP response on a Puppeteer render is a liability under load. A job queue with a polling endpoint (or a webhook back to the client) is the right pattern here.
- Usage-based limits with a better feedback loop. Free users get a fixed number of AI generations. The current implementation is a counter on the user row. Something closer to a proper quota system with clearer in-app feedback would reduce confusion.