Session 7i: Stripe test coverage gaps filled, dual-provider cutover documented (1042 tests)
This commit is contained in:
+35
-1
@@ -4,7 +4,41 @@
|
|||||||
2026-06-10
|
2026-06-10
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
SHIP BUILD v7.0 — Stripe Payment Infrastructure + Free-Tier Gating (Session 7h)
|
SHIP BUILD v7.1 — Stripe Route + Webhook Verification (Session 7i)
|
||||||
|
|
||||||
|
## Session 7i (2026-06-10) — SHIPPED
|
||||||
|
|
||||||
|
### Stripe checkout + webhook (no new routes — gap-fill on existing)
|
||||||
|
|
||||||
|
Pre-audit revealed Session 3.4 already shipped a fuller Stripe
|
||||||
|
integration than this session's spec asked for: route, sig verify,
|
||||||
|
all 4 event handlers with 48h grace, customer create + persist,
|
||||||
|
portal + status endpoints, founder-code system, and `users` ↔
|
||||||
|
`user_profiles` dual writes. Raw-body middleware was already correctly
|
||||||
|
positioned at `src/app.js:52` (before global `express.json()`).
|
||||||
|
|
||||||
|
What this session added on top:
|
||||||
|
- `tests/integration/stripe.test.js` — refactored stripe mock to a
|
||||||
|
singleton handle, then added two route-level tests:
|
||||||
|
1. `constructEvent` throws → route returns 400 with `{ error: /signature/i }`
|
||||||
|
2. valid signature → route dispatches to `handleWebhookEvent` and returns `{ received: true }`
|
||||||
|
- `tests/unit/stripeService.test.js` — added `customer.subscription.updated`
|
||||||
|
test covering portal-driven plan-change: maps `items.data[0].price.id`
|
||||||
|
back to a tier via `PRICE_MAP`, writes to both `users` + `user_profiles`,
|
||||||
|
clears grace.
|
||||||
|
- `docs/SYSTEM-MANIFEST.md` — appended a *Payments: dual-provider divergence*
|
||||||
|
subsection under § 8 Findings → Frontend ↔ Backend contract, documenting
|
||||||
|
that the Next.js `/api/checkout` still routes to NexaPay while Express
|
||||||
|
Stripe is wired but uncalled by the frontend, with a 4-step cutover
|
||||||
|
punch list for a follow-up session.
|
||||||
|
|
||||||
|
### Quality gates (all green)
|
||||||
|
- `npm test`: **1042 / 1042 passing** (delta +3 from 1039 baseline, 0 regressions)
|
||||||
|
- `web/npm run build`: clean
|
||||||
|
- License audit: third-party deps only permissive (MIT/Apache-2.0/BSD/ISC/MPL/BlueOak/CC-BY/0BSD)
|
||||||
|
- `curl https://api.vyndr.app/api/health` → `{"status":"healthy"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Session 7h (2026-06-10) — SHIPPED
|
## Session 7h (2026-06-10) — SHIPPED
|
||||||
|
|
||||||
|
|||||||
@@ -411,3 +411,17 @@
|
|||||||
{"ts":"2026-06-10T16:37:41.681Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
{"ts":"2026-06-10T16:37:41.681Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
{"ts":"2026-06-10T16:37:41.734Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
{"ts":"2026-06-10T16:37:41.734Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
{"ts":"2026-06-10T16:37:41.759Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
{"ts":"2026-06-10T16:37:41.759Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T17:34:53.024Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T17:34:53.071Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T17:34:53.071Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T17:34:53.071Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T17:34:53.133Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T17:34:53.150Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T17:34:53.671Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T17:38:50.233Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T17:38:50.381Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T17:38:50.408Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T17:38:50.409Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T17:38:50.409Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T17:38:50.501Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T17:38:51.619Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
|||||||
@@ -584,6 +584,29 @@ All frontend API paths discovered are either:
|
|||||||
handler. Spot-checked: `/api/players/search` (Next → Python),
|
handler. Spot-checked: `/api/players/search` (Next → Python),
|
||||||
`/api/scan` (Next → Express), `/api/intelligence/feed` (Next direct DB).
|
`/api/scan` (Next → Express), `/api/intelligence/feed` (Next direct DB).
|
||||||
|
|
||||||
|
#### Payments: dual-provider divergence (Session 7h)
|
||||||
|
|
||||||
|
The frontend `/api/checkout` (Next.js) creates **NexaPay** payment
|
||||||
|
links and is what `web/src/components/Pricing.tsx` CTAs currently hit.
|
||||||
|
The Express `POST /api/stripe/checkout` (Stripe Checkout Sessions) is
|
||||||
|
fully wired, tested in test mode against real Stripe resources
|
||||||
|
(products + prices + webhook all created), and ready for traffic —
|
||||||
|
but no frontend caller invokes it yet. Cutover work for a follow-up
|
||||||
|
session:
|
||||||
|
|
||||||
|
1. Replace `web/src/app/api/checkout/route.ts` body to fetch
|
||||||
|
`${BACKEND_URL}/api/stripe/checkout` with the user's bearer token
|
||||||
|
instead of calling NexaPay's `createPaymentLink`.
|
||||||
|
2. Wire `Pricing.tsx` CTAs through that same Next.js route (response
|
||||||
|
shape is already `{ url, ... }`-compatible; Express returns
|
||||||
|
`{ checkout_url, session_id }`, so the proxy needs to remap
|
||||||
|
`checkout_url → url`).
|
||||||
|
3. Add `/upgrade/success?session_id=...` and `/upgrade/cancel` pages.
|
||||||
|
Current Stripe `success_url` points at `/scan?upgraded=true` and
|
||||||
|
`cancel_url` at `/#pricing` — those work but a confirmation page
|
||||||
|
reads better.
|
||||||
|
4. Decide on NexaPay: keep as fallback, remove, or feature-flag.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. How to update this manifest
|
## 9. How to update this manifest
|
||||||
|
|||||||
@@ -12,30 +12,32 @@ jest.mock('../../src/utils/supabase', () => ({
|
|||||||
getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
|
getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock Stripe
|
// Mock Stripe — singleton handle so tests can override constructEvent
|
||||||
jest.mock('stripe', () => {
|
// per-case. The service caches `new Stripe()` once; the mock returns
|
||||||
return jest.fn().mockImplementation(() => ({
|
// the same instance every time so the override applies to the cached
|
||||||
customers: {
|
// reference too.
|
||||||
create: jest.fn().mockResolvedValue({ id: 'cus_test123' }),
|
const mockStripeInstance = {
|
||||||
|
customers: {
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'cus_test123' }),
|
||||||
|
},
|
||||||
|
checkout: {
|
||||||
|
sessions: {
|
||||||
|
create: jest.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/test', id: 'cs_test' }),
|
||||||
},
|
},
|
||||||
checkout: {
|
},
|
||||||
sessions: {
|
billingPortal: {
|
||||||
create: jest.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/test', id: 'cs_test' }),
|
sessions: {
|
||||||
},
|
create: jest.fn().mockResolvedValue({ url: 'https://billing.stripe.com/test' }),
|
||||||
},
|
},
|
||||||
billingPortal: {
|
},
|
||||||
sessions: {
|
subscriptions: {
|
||||||
create: jest.fn().mockResolvedValue({ url: 'https://billing.stripe.com/test' }),
|
list: jest.fn().mockResolvedValue({ data: [] }),
|
||||||
},
|
},
|
||||||
},
|
webhooks: {
|
||||||
subscriptions: {
|
constructEvent: jest.fn(),
|
||||||
list: jest.fn().mockResolvedValue({ data: [] }),
|
},
|
||||||
},
|
};
|
||||||
webhooks: {
|
jest.mock('stripe', () => jest.fn(() => mockStripeInstance));
|
||||||
constructEvent: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('axios');
|
jest.mock('axios');
|
||||||
process.env.ODDS_API_KEY = 'test';
|
process.env.ODDS_API_KEY = 'test';
|
||||||
@@ -154,4 +156,44 @@ describe('POST /api/stripe/webhook', () => {
|
|||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns 400 when signature header is present but invalid', async () => {
|
||||||
|
// Header present, but constructEvent throws — Stripe's real behavior
|
||||||
|
// when the signed payload doesn't match the secret. The route must
|
||||||
|
// surface 400 and never invoke the event-dispatch path.
|
||||||
|
mockStripeInstance.webhooks.constructEvent.mockImplementationOnce(() => {
|
||||||
|
throw new Error('No signatures found matching the expected signature');
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/stripe/webhook')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.set('stripe-signature', 't=1700000000,v1=deadbeef')
|
||||||
|
.send(Buffer.from('{"id":"evt_forged","type":"checkout.session.completed"}'))
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(res.body.error).toMatch(/signature/i);
|
||||||
|
expect(mockStripeInstance.webhooks.constructEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid signature dispatches to handler and returns 200', async () => {
|
||||||
|
// Positive case: route → service → 200 with `received: true`. We can't
|
||||||
|
// observe the supabase write here without entangling with auth setup;
|
||||||
|
// the unit suite (stripeService.test.js) already proves the dispatch
|
||||||
|
// wiring per event type. This test pins the route-level contract.
|
||||||
|
mockStripeInstance.webhooks.constructEvent.mockImplementationOnce(() => ({
|
||||||
|
id: 'evt_ok',
|
||||||
|
type: 'invoice.payment_failed', // chosen because it requires no users-table fixture beyond the default
|
||||||
|
data: { object: { customer: 'cus_test123' } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/stripe/webhook')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.set('stripe-signature', 't=1700000000,v1=stub')
|
||||||
|
.send(Buffer.from('{}'))
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -127,6 +127,31 @@ describe('stripeService', () => {
|
|||||||
expect(profilesUpdate.patch.subscription_status).toBe('grace_period');
|
expect(profilesUpdate.patch.subscription_status).toBe('grace_period');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('customer.subscription.updated active → flips tier to the new plan + clears grace', async () => {
|
||||||
|
// Plan-change flow (portal-driven upgrade/downgrade). Stripe sends
|
||||||
|
// the new price on items.data[0].price.id; the service must map it
|
||||||
|
// back to a tier via PRICE_MAP and reflect that on the user.
|
||||||
|
const fake = makeFake();
|
||||||
|
mockSupabaseClient.current = fake;
|
||||||
|
const newPriceId = process.env.STRIPE_PRICE_DESK || 'price_desk_monthly';
|
||||||
|
await handleWebhookEvent({
|
||||||
|
type: 'customer.subscription.updated',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
customer: 'cus_active',
|
||||||
|
status: 'active',
|
||||||
|
items: { data: [{ price: { id: newPriceId } }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const usersUpdate = fake.updates.find((u) => u.table === 'users');
|
||||||
|
const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles');
|
||||||
|
expect(usersUpdate.patch.tier).toBe('desk');
|
||||||
|
expect(usersUpdate.patch.grace_period_until).toBeNull();
|
||||||
|
expect(profilesUpdate.patch.tier).toBe('desk');
|
||||||
|
expect(profilesUpdate.patch.subscription_status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
test('customer.subscription.deleted sets grace, does not flip tier immediately', async () => {
|
test('customer.subscription.deleted sets grace, does not flip tier immediately', async () => {
|
||||||
const fake = makeFake();
|
const fake = makeFake();
|
||||||
mockSupabaseClient.current = fake;
|
mockSupabaseClient.current = fake;
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user