M0: foundation — monorepo, PostGIS schema, deck query, app shell

Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.

- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
  clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
  graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
  the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
  that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfowi
2026-08-20 13:32:35 -04:00
co-authored by Claude Opus 5
commit 19623bcccb
66 changed files with 12412 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { MoneyError, formatCents, parseAmountToCents, platformFee, proPayout, splitCharge } from '../src/money';
describe('platformFee', () => {
it('takes the stated percentage', () => {
expect(platformFee(10_000, 1500)).toBe(1500);
expect(platformFee(15_000, 1200)).toBe(1800);
});
it('rounds half-up to whole cents', () => {
expect(platformFee(333, 1500)).toBe(50); // 49.95 -> 50
expect(platformFee(1, 1500)).toBe(0); // 0.15 -> 0
});
it('handles the boundaries', () => {
expect(platformFee(10_000, 0)).toBe(0);
expect(platformFee(10_000, 10_000)).toBe(10_000);
});
it('rejects nonsense rates', () => {
expect(() => platformFee(10_000, -1)).toThrow(MoneyError);
expect(() => platformFee(10_000, 10_001)).toThrow(MoneyError);
expect(() => platformFee(10_000, 12.5)).toThrow(MoneyError);
});
it('rejects non-integer amounts — floats never reach Stripe', () => {
expect(() => platformFee(99.99, 1500)).toThrow(MoneyError);
expect(() => platformFee(-100, 1500)).toThrow(MoneyError);
});
});
describe('splitCharge', () => {
it('always sums back to the original amount', () => {
for (const amount of [1, 7, 333, 999, 10_000, 123_457]) {
const { fee, payout } = splitCharge(amount, 1500);
expect(fee + payout).toBe(amount);
}
});
it('agrees with proPayout', () => {
expect(splitCharge(20_000, 1500).payout).toBe(proPayout(20_000, 1500));
});
});
describe('parseAmountToCents', () => {
it('parses the shapes a human types', () => {
expect(parseAmountToCents('150')).toBe(15_000);
expect(parseAmountToCents('150.50')).toBe(15_050);
expect(parseAmountToCents('150,50')).toBe(15_050);
expect(parseAmountToCents('€150.50')).toBe(15_050);
});
it('rounds to the nearest cent rather than truncating', () => {
expect(parseAmountToCents('10.999')).toBe(1100);
});
it('throws on junk', () => {
expect(() => parseAmountToCents('abc')).toThrow(MoneyError);
});
});
describe('formatCents', () => {
it('renders whole currency units', () => {
expect(formatCents(15_000)).toContain('150');
});
});