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:
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cancellationOutcome } from '../src/cancellation';
|
||||
|
||||
const NOW = new Date('2026-06-01T12:00:00Z');
|
||||
const inHours = (h: number) => new Date(NOW.getTime() + h * 3_600_000);
|
||||
|
||||
describe('cancellationOutcome', () => {
|
||||
it('refunds in full when the client cancels well ahead', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(48),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents).toBe(20_000);
|
||||
expect(out.feeCents).toBe(0);
|
||||
});
|
||||
|
||||
it('charges a fee when the client cancels inside the window', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(3),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.feeCents).toBe(5_000); // 25%
|
||||
expect(out.refundCents).toBe(15_000);
|
||||
});
|
||||
|
||||
it('treats exactly 24h out as still free', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(24),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.feeCents).toBe(0);
|
||||
});
|
||||
|
||||
it('never charges the client when the pro drops the job', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(1),
|
||||
cancelledBy: 'pro',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents).toBe(20_000);
|
||||
expect(out.feeCents).toBe(0);
|
||||
});
|
||||
|
||||
it('refunds in full on an admin cancellation', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(1),
|
||||
cancelledBy: 'admin',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents).toBe(20_000);
|
||||
});
|
||||
|
||||
it('always splits the full amount, whatever the branch', () => {
|
||||
for (const hours of [-5, 0, 1, 23.9, 24, 100]) {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 12_345,
|
||||
scheduledStart: inHours(hours),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents + out.feeCents).toBe(12_345);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
UNRATED_BASELINE,
|
||||
newProBoost,
|
||||
proximityScore,
|
||||
recencyScore,
|
||||
score,
|
||||
smoothedRating,
|
||||
} from '../src/ranking';
|
||||
|
||||
const NOW = new Date('2026-06-01T12:00:00Z');
|
||||
const daysAgo = (n: number) => new Date(NOW.getTime() - n * 86_400_000);
|
||||
|
||||
describe('smoothedRating', () => {
|
||||
it('falls back to the baseline for an unrated pro', () => {
|
||||
expect(smoothedRating(null, 0)).toBe(UNRATED_BASELINE);
|
||||
});
|
||||
|
||||
it('does not let one 5-star review top the deck', () => {
|
||||
const oneReview = smoothedRating(5, 1);
|
||||
const manyReviews = smoothedRating(4.8, 50);
|
||||
expect(oneReview).toBeLessThan(manyReviews);
|
||||
});
|
||||
|
||||
it('converges on the true rating as reviews accumulate', () => {
|
||||
expect(smoothedRating(4.8, 500)).toBeCloseTo(4.8, 1);
|
||||
});
|
||||
|
||||
it('pulls a single bad review up toward the baseline too', () => {
|
||||
expect(smoothedRating(1, 1)).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proximityScore', () => {
|
||||
it('is 1 next door and 0 at the radius edge', () => {
|
||||
expect(proximityScore(0, 10_000)).toBe(1);
|
||||
expect(proximityScore(10_000, 10_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps beyond the radius rather than going negative', () => {
|
||||
expect(proximityScore(50_000, 10_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles a zero radius without dividing by zero', () => {
|
||||
expect(proximityScore(100, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recencyScore', () => {
|
||||
it('rewards active pros and decays dormant ones to zero', () => {
|
||||
expect(recencyScore(NOW, NOW)).toBe(1);
|
||||
expect(recencyScore(daysAgo(7), NOW)).toBeCloseTo(0.5, 1);
|
||||
expect(recencyScore(daysAgo(30), NOW)).toBe(0);
|
||||
});
|
||||
|
||||
it('scores a pro who has never been active at zero', () => {
|
||||
expect(recencyScore(null, NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('newProBoost', () => {
|
||||
it('gives fresh supply a decaying head start', () => {
|
||||
expect(newProBoost(NOW, NOW)).toBe(1);
|
||||
expect(newProBoost(daysAgo(15), NOW)).toBeCloseTo(0.5, 1);
|
||||
expect(newProBoost(daysAgo(60), NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('score', () => {
|
||||
const base = {
|
||||
ratingAvg: 4.5,
|
||||
ratingCount: 20,
|
||||
responseRate: 0.9,
|
||||
distanceM: 2_000,
|
||||
serviceRadiusM: 10_000,
|
||||
lastActiveAt: NOW,
|
||||
createdAt: daysAgo(200),
|
||||
now: NOW,
|
||||
};
|
||||
|
||||
it('always lands in 0..1', () => {
|
||||
expect(score(base)).toBeGreaterThan(0);
|
||||
expect(score(base)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('ranks the closer of two identical pros higher', () => {
|
||||
const near = score({ ...base, distanceM: 500 });
|
||||
const far = score({ ...base, distanceM: 9_000 });
|
||||
expect(near).toBeGreaterThan(far);
|
||||
});
|
||||
|
||||
it('ranks the more responsive of two identical pros higher', () => {
|
||||
expect(score({ ...base, responseRate: 0.95 })).toBeGreaterThan(
|
||||
score({ ...base, responseRate: 0.2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not bury a brand-new pro beneath an established one', () => {
|
||||
const newbie = score({ ...base, ratingAvg: null, ratingCount: 0, responseRate: null, createdAt: NOW });
|
||||
expect(newbie).toBeGreaterThan(0.3);
|
||||
});
|
||||
|
||||
it('assumes an unknown response rate is average rather than terrible', () => {
|
||||
const unknown = score({ ...base, responseRate: null });
|
||||
const terrible = score({ ...base, responseRate: 0 });
|
||||
expect(unknown).toBeGreaterThan(terrible);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
TransitionError,
|
||||
assertTransition,
|
||||
canTransition,
|
||||
isTerminal,
|
||||
nextStates,
|
||||
} from '../src/state-machines';
|
||||
|
||||
describe('booking transitions', () => {
|
||||
it('walks the happy path', () => {
|
||||
expect(canTransition('booking', 'scheduled', 'in_progress')).toBe(true);
|
||||
expect(canTransition('booking', 'in_progress', 'awaiting_confirmation')).toBe(true);
|
||||
expect(canTransition('booking', 'awaiting_confirmation', 'completed')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to skip straight to completed — the payout guard', () => {
|
||||
expect(canTransition('booking', 'scheduled', 'completed')).toBe(false);
|
||||
expect(() => assertTransition('booking', 'scheduled', 'completed')).toThrow(TransitionError);
|
||||
});
|
||||
|
||||
it('cannot resurrect a cancelled booking', () => {
|
||||
expect(isTerminal('booking', 'cancelled')).toBe(true);
|
||||
expect(canTransition('booking', 'cancelled', 'scheduled')).toBe(false);
|
||||
});
|
||||
|
||||
it('still allows a dispute after completion', () => {
|
||||
expect(canTransition('booking', 'completed', 'disputed')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown states instead of silently allowing them', () => {
|
||||
expect(canTransition('booking', 'nonsense', 'completed')).toBe(false);
|
||||
expect(nextStates('booking', 'nonsense')).toEqual([]);
|
||||
});
|
||||
|
||||
it('every declared status appears in the graph', () => {
|
||||
for (const status of BOOKING_STATUSES) {
|
||||
expect(() => nextStates('booking', status)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('payment transitions', () => {
|
||||
it('holds before it releases', () => {
|
||||
expect(canTransition('payment', 'pending', 'held')).toBe(true);
|
||||
expect(canTransition('payment', 'held', 'released')).toBe(true);
|
||||
});
|
||||
|
||||
it('never releases straight from pending — money only moves after capture', () => {
|
||||
expect(canTransition('payment', 'pending', 'released')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a failed payment be retried', () => {
|
||||
expect(canTransition('payment', 'failed', 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a full refund as final', () => {
|
||||
expect(isTerminal('payment', 'refunded')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verification transitions', () => {
|
||||
it('requires review before a pro is verified', () => {
|
||||
expect(canTransition('verification', 'draft', 'verified')).toBe(false);
|
||||
expect(canTransition('verification', 'draft', 'pending')).toBe(true);
|
||||
expect(canTransition('verification', 'pending', 'verified')).toBe(true);
|
||||
});
|
||||
|
||||
it('can suspend a verified pro and reinstate them', () => {
|
||||
expect(canTransition('verification', 'verified', 'suspended')).toBe(true);
|
||||
expect(canTransition('verification', 'suspended', 'verified')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a rejected pro reapply', () => {
|
||||
expect(canTransition('verification', 'rejected', 'pending')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request transitions', () => {
|
||||
it('is one-shot — an accepted request cannot change', () => {
|
||||
expect(isTerminal('request', 'accepted')).toBe(true);
|
||||
expect(canTransition('request', 'accepted', 'declined')).toBe(false);
|
||||
expect(canTransition('request', 'expired', 'accepted')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user