Skip to content

Commit 2d24004

Browse files
committed
feat: add activity recording for invoice issuance in PATCH endpoint
1 parent 315242b commit 2d24004

2 files changed

Lines changed: 41 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
title: Charging for field work without the WhatsApp API
3+
description: Not all my work is code. I needed to charge for on-site tech support from my phone, out in the street, without paying for the WhatsApp Business API or exposing the amount in a link anyone could edit.
4+
date: 2026-07-16
5+
tags: [payments, security, product]
6+
lang: en
7+
translationOf: cobrar-un-trabajo-de-campo-sin-api-de-whatsapp
8+
---
9+
10+
Not everything I do is writing software. I also fix computers, install networks, do on-site support — work that gets charged at the end of the visit, standing up, with the client next to you. The real way to charge for that isn't a checkout with a cart: it's a number, a link, and "pay me here". I wanted that link to arrive over WhatsApp, without paying for the WhatsApp Business API (designed for volume, not for a freelancer sending three messages a day) and without giving up anything I'd already built into the gateway that runs `/pay`: real idempotency, Wompi signature verification, and a state machine that never lets an approved payment go backwards.
11+
12+
## Reuse the gateway, don't duplicate it
13+
14+
The easy temptation was building a separate "collections" system: another table, another flow, another state logic. I resisted it. A field collection *is* a payment — same `payments` table, same idempotency, same Wompi webhooks, same signature verification. All I added to the row were five columns: the payer's phone, where it was born (`pay` vs `cobro`), a short code for the link, an optional expiry, and a soft link to the client record if they already existed in the CRM. Zero new tables, zero parallel state machine. Voiding a collection goes through the same `applyGatewayEvent` that processes a real webhook: the `approved → voided` transition that already existed for manual refunds is, without changing a line, also the "Void" button on the field screen.
15+
16+
## The amount never travels in the link
17+
18+
The link WhatsApp delivers is `/c/AB3K9F` — a six-character code, nothing else. The temptation to put the amount straight in the URL (`/c/AB3K9F?amount=150000`) is what opens the door to someone editing it before paying. Instead, the server looks up the payment by its code and signs the Wompi parameters *at click time* — the same integrity pattern the public checkout already used, only now the trigger is a short code instead of a form. The client never sees, and can never touch, a figure other than the one I configured.
19+
20+
## A phone number is not a password
21+
22+
The second problem was history: I wanted the client to see their past payments without me having to build an accounts system. The obvious solution — "look it up by your phone number" — has an evident flaw: anyone who knows someone else's number would see their complete payment history. A phone number gets shared constantly; it isn't a secret.
23+
24+
So I split access into two levels. Every WhatsApp message carries a link with an HMAC of the phone number — a credential only I can generate because only I have the server secret — and that link shows the full history. But I also left open a manual lookup by number alone, for when someone loses the link: that view returns real dates and statuses, but the amount arrives masked (`$ •••.500`) and with a limit of five lookups per hour. Enough for the owner to recognize their own payment; useless for a third party trying to profile what I charge whom.
25+
26+
## Authorizing without a session
27+
28+
A detail I didn't anticipate until implementing it: in test mode (with no Wompi keys configured), the endpoint that simulates the gateway required an admin session or ownership of a client-portal invoice. Neither applies to someone who just received a link over WhatsApp. The fix was treating the short code as what it is — proof of possession, no different in spirit from a portal client proving they own their invoice — and accepting it as one more authorization path, compared in constant time so that not even the response timing leaks whether a code is nearly right.
29+
30+
## What I learned about mixing conventions
31+
32+
The most annoying bug wasn't a security one but an environment one: part of this repo's code reads variables with `import.meta.env` and part with `process.env`, and they aren't interchangeable — Astro's dev server loads `.env` only into the first, and Vercel in production only injects into the second. I wrote the code reading a single source and it worked perfectly in production and failed silently locally: the signed history link always fell back to the backup form, as if the secret didn't exist. The bug didn't show up as an error — it showed up as a polite degradation to a secondary path, which is the worst kind of bug because it breaks nothing, it just makes sure the good thing never activates. The fix was a helper that checks both sources; the lesson was not to trust that "works on my machine" and "works in production" measure the same thing when the environment has two different ways of loading configuration.

src/pages/api/admin/portal/facturas.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '../../../../lib/portal/invoices'
99
import { notifyClient } from '../../../../lib/portal/notifications'
1010
import { formatMoney } from '../../../../lib/portal/format'
11+
import { recordActivity } from '../../../../lib/portal/activity'
1112

1213
// CRUD de facturas desde el panel. La sesión de admin la impone el middleware.
1314

@@ -140,6 +141,14 @@ export const PATCH: APIRoute = async ({ request }) => {
140141
href: `/portal/facturas/${invoice.id}`,
141142
emailCta: 'Ver y pagar la factura',
142143
})
144+
await recordActivity({
145+
clientId: invoice.clientId,
146+
projectId: invoice.projectId ?? null,
147+
type: 'invoice',
148+
title: `Factura ${invoice.number} emitida`,
149+
detail: formatMoney(invoice.totalCents, invoice.currency),
150+
href: `/portal/facturas/${invoice.id}`,
151+
})
143152

144153
return json(200, { ok: true, number: invoice.number })
145154
}

0 commit comments

Comments
 (0)