Automatic Payment Receipts & History API for PlayaMXCRM
TL;DR: Added a fully‑typed receipt generation endpoint with token‑based URLs, fixed date handling, and exposed a tenant/owner‑specific payment history. The changes touch the API layer, unit tests, and the React UI, turning a static PDF link into a reliable, searchable receipt service.
The Problem
Our rental‑management portal needed real‑time, per‑tenant payment receipts that could be opened from the UI and stored in a history view. The existing implementation only generated a PDF on demand with a hard‑coded token and displayed dates with timezone offsets, causing:
-
Broken links – the token was sometimes
undefined(entry?.receiptTokentypo). - Incorrect dates – receipts showed the server’s UTC midnight instead of the tenant’s local day, leading to confusion in the timeline component.
- No historical view – tenants and owners could not see past receipts, forcing manual email look‑ups.
The symptom in the UI was a 404 when clicking “Open receipt”, and the API tests (recibos-e-historial.test.ts) failed with mismatched date objects.
What I Tried First
My initial fix was a quick patch in apps/web/src/app/income/[id]/page.tsx:
// before
if (entry?.receiptToken) {
window.open(`/recibo/${entry.receiptToke`);
}
I added a guard and a fallback URL, hoping the missing token was the only issue. The UI stopped throwing a 404, but the underlying problems persisted:
- The token generation still produced non‑URL‑safe strings.
- Dates remained offset because
new Date()was used directly in the timeline. - No endpoint existed to list historical receipts, so the UI could not render a history list.
The test suite still reported failures around date handling and missing receipt history.
The Implementation
1. Token Generation & URL Helper (receipts.ts)
I introduced a deterministic, URL‑safe token generator and a helper to build the receipt URL.
// apps/api/src/finance/receipts.ts
import { randomBytes } from "crypto";
/** Generate a URL‑safe token (18 bytes → 24 chars) */
export function newReceiptToken(): string {
return randomBytes(18).toString("base64url");
}
/** Build the public receipt URL */
export function receiptUrl(token: string): string {
return `${process.env.WEB_URL}/recibo/${token}`;
}
Why this change?
randomBytes(...).toString("base64url") guarantees no +, /, or = characters that break URLs. The helper centralises URL construction, making future domain changes trivial.
2. Date Normalisation (receipts.ts)
The receipt now stores calendar day (midnight UTC) instead of a full timestamp. I added a utility comment and adjusted the model:
/** Día de calendario (YYYY-MM-DD) – stored as midnight UTC */
export interface Receipt {
token: string;
amount: number;
issuedAt: string; // e.g. "2026-10-13"
}
When creating a receipt, we strip the time component:
const issuedAt = new Date().toISOString().split("T")[0]; // "2026-10-13"
3. API Endpoint & Service (finance.controller.ts & finance.service.ts)
A new POST /receipts endpoint creates a receipt and returns the URL:
// apps/api/src/finance/finance.controller.ts
@Post("receipts")
async createReceipt(@Body() dto: CreateReceiptDto) {
const receipt = await this.financeService.generateReceipt(dto);
return { url: receiptUrl(receipt.token) };
}
The service handles token creation and persistence:
// apps/api/src/finance/finance.service.ts
async generateReceipt(dto: CreateReceiptDto): Promise<Receipt> {
const token = newReceiptToken();
const receipt: Receipt = {
token,
amount: dto.amount,
issuedAt: new Date().toISOString().split("T")[0],
};
await this.db.receipts.insert(receipt);
return receipt;
}
4. Historical Endpoint (timeline.controller.ts)
To expose a tenant/owner‑specific receipt list, I added a GET route that queries by user ID and orders by issuedAt descending.
// apps/api/src/timeline/timeline.controller.ts
@Get("receipts/:userId")
async getReceiptHistory(@Param("userId") userId: string) {
return this.db.receipts.find({ userId }).sort({ issuedAt: -1 });
}
5. Unit Tests (recibos-e-historial.test.ts)
The test suite now validates token generation, URL formation, and date handling:
// apps/api/src/__tests__/recibos-e-historial.test.ts
describe("Fechas del recibo (día de calendario)", () => {
it("stores issuedAt as YYYY-MM-DD", async () => {
const receipt = await financeService.generateReceipt({ amount: 1200 });
expect(receipt.issuedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
I also added a new test for the history endpoint:
it("returns receipts ordered by issuedAt desc", async () => {
const history = await timelineController.getReceiptHistory("tenant123");
expect(history[0].issuedAt >= history[1].issuedAt).toBeTruthy();
});
6. UI Adjustments
a. Opening a receipt (income/[id]/page.tsx)
// apps/web/src/app/income/[id]/page.tsx
async function openReceipt() {
if (entry?.receiptToken) {
const url = `/recibo/${entry.receiptToken}`;
window.open(url, "_blank", "noopener");
}
}
Now the token is guaranteed to exist, and the URL is built correctly.
b. Timeline component (_components/Timeline.tsx)
I added a comment and a conditional to display dates correctly:
// apps/web/src/app/_components/Timeline.tsx
// Fechas capturadas como día (medianoche UTC) se muestran tal cual; las que
// traen hora real, en hora de Cancún, se convierten.
function formatDate(dateStr: string) {
return dateStr.includes("T")
? new Date(dateStr).toLocaleString("es-MX", { timeZone: "America/Cancun" })
: dateStr; // already YYYY-MM-DD
}
c. Receipt page (recibo/[token]/page.tsx)
The page now fetches receipt data by token and renders a clean view:
// apps/web/src/app/recibo/[token]/page.tsx
export default async function ReceiptPage({ params }: { params: { token: string } }) {
const receipt = await api.get<Receipt>(`/api/receipts/${params.token}`);
return (
<section>
<h1>Recibo #{receipt.token}</h1>
<p>Monto: ${receipt.amount}</p>
<p>Fecha: {receipt.issuedAt}</p>
</section>
);
}
7. Documentation (CLAUDE.md)
I updated the keep‑alive docs to note the new /api/wake endpoint, but that change is orthogonal to the receipt flow.
Key Takeaway
**Never assume token strings are URL‑safe; always generate them with a base64url scheme and centralise
Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.
Repo: zaerohell/VS · 2026-10-13
#playadev #buildinpublic
Top comments (0)