From 1e488517a0ae906cb8ce3296d3cc30ed36402845 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sat, 23 May 2026 23:50:43 -0400 Subject: [PATCH] Add admin checkout session regeneration for payment_pending claims --- api/admin/create-checkout-session.js | 31 ++++++++++++++++++++++------ public/admin/claims.html | 4 ++-- tests/api-payments.test.js | 9 +++++++- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/api/admin/create-checkout-session.js b/api/admin/create-checkout-session.js index 10f646f..f4276b8 100644 --- a/api/admin/create-checkout-session.js +++ b/api/admin/create-checkout-session.js @@ -54,6 +54,7 @@ module.exports = async function handler(req, res) { const body = req.body || {}; const claimId = typeof body.claimId === 'string' ? body.claimId.trim() : ''; + const forceNew = body.forceNew === true; if (!claimId) return res.status(400).json({ ok: false, status: 'INVALID_CLAIM_ID' }); let stripe; @@ -83,7 +84,7 @@ module.exports = async function handler(req, res) { return asConflict(res, 'CLAIM_NOT_READY_FOR_PAYMENT', 'Claim must be cards_published before creating checkout.'); } - if (claim.status === 'payment_pending' && claim.stripe_checkout_session_id) { + if (claim.status === 'payment_pending' && claim.stripe_checkout_session_id && !forceNew) { return res.status(200).json({ ok: true, status: 'CHECKOUT_SESSION_CREATED', @@ -146,8 +147,13 @@ module.exports = async function handler(req, res) { await db.query( `insert into claim_payments (claim_id, provider, stripe_checkout_session_id, amount_cents, currency, status, metadata_json) values ($1, 'stripe', $2, $3, 'usd', 'pending', $4::jsonb) - on conflict (stripe_checkout_session_id) - do update set status = excluded.status, metadata_json = excluded.metadata_json, updated_at = now()`, + on conflict (claim_id, provider) + do update set stripe_checkout_session_id = excluded.stripe_checkout_session_id, + amount_cents = excluded.amount_cents, + currency = excluded.currency, + status = excluded.status, + metadata_json = excluded.metadata_json, + updated_at = now()`, [claimId, session.id, 2000, JSON.stringify({ checkoutUrl: session.url || null })] ); @@ -163,10 +169,17 @@ module.exports = async function handler(req, res) { [claimId, 2000, session.id] ); + const eventType = forceNew && fromStatus === 'payment_pending' + ? 'payment.checkout_regenerated' + : 'payment.checkout_created'; + const eventMessage = forceNew && fromStatus === 'payment_pending' + ? 'Stripe checkout regenerated.' + : 'Stripe checkout created.'; + await db.query( `insert into claim_events (claim_id, event_type, actor, message, event_json) - values ($1, 'payment.checkout_created', 'system', $2, $3::jsonb)`, - [claimId, 'Stripe checkout created.', JSON.stringify({ sessionId: session.id, checkoutUrl: session.url || null })] + values ($1, $2, 'system', $3, $4::jsonb)`, + [claimId, eventType, eventMessage, JSON.stringify({ sessionId: session.id, checkoutUrl: session.url || null })] ); if (fromStatus === 'cards_published') { @@ -177,7 +190,13 @@ module.exports = async function handler(req, res) { ); } - return res.status(200).json({ ok: true, status: 'CHECKOUT_SESSION_CREATED', claimId, checkoutUrl: session.url || null, sessionId: session.id }); + return res.status(200).json({ + ok: true, + status: forceNew && fromStatus === 'payment_pending' ? 'CHECKOUT_SESSION_REGENERATED' : 'CHECKOUT_SESSION_CREATED', + claimId, + checkoutUrl: session.url || null, + sessionId: session.id + }); } catch (error) { console.error('ADMIN_CREATE_CHECKOUT_SESSION_UNEXPECTED', { message: error?.message, code: error?.code, claimId }); return res.status(500).json({ ok: false, status: 'ADMIN_CREATE_CHECKOUT_SESSION_FAILED', error: 'Failed to create checkout session.' }); diff --git a/public/admin/claims.html b/public/admin/claims.html index c106dd2..5721298 100644 --- a/public/admin/claims.html +++ b/public/admin/claims.html @@ -61,7 +61,7 @@

CommandLayer Claims Admin

Internal operator dashboard f async function loadDetail(id){s.selected=id;renderClaims();const r=await fetch(`/api/admin/claim?claimId=${encodeURIComponent(id)}`,{headers:{Authorization:headers().Authorization}});const d=await r.json();if(!r.ok||!d.ok)return; s.detail=d; renderDetail();} async function action(action,p={}){const r=await fetch('/api/admin/claim-action',{method:'POST',headers:headers(),body:JSON.stringify({claimId:s.selected,action,actor:'admin',...p})});const d=await r.json();if(!r.ok||!d.ok){s.error=`${d.status}: ${d.error}`;renderDetail();return;}s.error=null;await loadClaims();await loadDetail(s.selected);} async function publish(){const r=await fetch('/api/admin/publish-agent-cards',{method:'POST',headers:headers(),body:JSON.stringify({claimId:s.selected})});const d=await r.json();if(!r.ok||!d.ok){s.error=`${d.status}: ${d.error}`;renderDetail();return;}s.error=null;await loadClaims();await loadDetail(s.selected);} -async function createCheckoutSession(claimId){if(!claimId){s.error='400 — claimId is required';renderDetail();return;}s.error=null;s.checkoutUrl=null;s.checkoutLoading=true;renderDetail();try{const r=await fetch('/api/admin/create-checkout-session',{method:'POST',headers:headers(),body:JSON.stringify({claimId})});const d=await r.json().catch(()=>({}));if(!r.ok||!d.ok){const detail=d?.debug?.message?`\nDetails: ${d.debug.message}`:'';s.error=`Checkout failed:\n${d.status||r.status} — ${d.error||'Request failed'}${detail}`;return;}s.checkoutUrl=d.checkoutUrl||d.url||null;await loadClaims();await loadDetail(claimId);}catch(e){s.error=`500 — ${e?.message||'Request failed'}`;}finally{s.checkoutLoading=false;renderDetail();}} +async function createCheckoutSession(claimId,forceNew=false){if(!claimId){s.error='400 — claimId is required';renderDetail();return;}s.error=null;s.checkoutUrl=null;s.checkoutLoading=true;renderDetail();try{const r=await fetch('/api/admin/create-checkout-session',{method:'POST',headers:headers(),body:JSON.stringify({claimId,forceNew})});const d=await r.json().catch(()=>({}));if(!r.ok||!d.ok){const detail=d?.debug?.message?`\nDetails: ${d.debug.message}`:'';s.error=`Checkout failed:\n${d.status||r.status} — ${d.error||'Request failed'}${detail}`;return;}s.checkoutUrl=d.checkoutUrl||d.url||null;await loadClaims();await loadDetail(claimId);}catch(e){s.error=`500 — ${e?.message||'Request failed'}`;}finally{s.checkoutLoading=false;renderDetail();}} function checkoutUrlFromClaim(claim){return s.checkoutUrl || claim?.stripe_checkout_url || claim?.payment_checkout_url || null;} function openCheckout(claim){const url=checkoutUrlFromClaim(claim);if(url)window.open(url,'_blank');} function copyCheckout(claim){const url=checkoutUrlFromClaim(claim);if(url)navigator.clipboard.writeText(url);} @@ -72,7 +72,7 @@

CommandLayer Claims Admin

Internal operator dashboard f if(claim.status==='created'){mk('Approve',()=>action('approve',{notes:document.getElementById('notesInput').value}),'btn btn-primary');mk('Reject',()=>action('reject',{reason:document.getElementById('reasonInput').value}),'btn btn-secondary');mk('Mark failed',()=>action('mark_failed',{reason:document.getElementById('reasonInput').value}),'btn btn-danger');} if(claim.status==='approved'){mk('Publish agent cards',()=>publish(),'btn btn-primary');mk('Mark failed',()=>action('mark_failed',{reason:document.getElementById('reasonInput').value}),'btn btn-danger');mk('Add note',()=>action('add_note',{notes:document.getElementById('notesInput').value,reason:document.getElementById('reasonInput').value}),'btn');} if(claim.status==='cards_published'){if(missing)mk('Repair / Publish cards',()=>publish(),'btn btn-primary');mk(s.checkoutLoading?'Creating checkout...':'Create $20 checkout',()=>createCheckoutSession(claim.claim_id),'btn btn-primary',{disabled:s.checkoutLoading,id:'createCheckoutBtn'});mk('Add note',()=>action('add_note',{notes:document.getElementById('notesInput').value,reason:document.getElementById('reasonInput').value}),'btn');} -if(claim.status==='payment_pending'){mk('Open checkout',()=>openCheckout(claim),'btn btn-secondary');mk('Copy checkout URL',()=>copyCheckout(claim),'btn btn-secondary');mk('Add note',()=>action('add_note',{notes:document.getElementById('notesInput').value,reason:document.getElementById('reasonInput').value}),'btn');} +if(claim.status==='payment_pending'){mk('Open checkout',()=>openCheckout(claim),'btn btn-secondary');mk('Copy checkout URL',()=>copyCheckout(claim),'btn btn-secondary');mk(s.checkoutLoading?'Creating checkout...':'Regenerate checkout session',()=>createCheckoutSession(claim.claim_id,true),'btn btn-primary',{disabled:s.checkoutLoading,id:'regenerateCheckoutBtn'});mk('Add note',()=>action('add_note',{notes:document.getElementById('notesInput').value,reason:document.getElementById('reasonInput').value}),'btn');} if(claim.status==='failed'||claim.status==='rejected'){mk('Add note',()=>action('add_note',{notes:document.getElementById('notesInput').value,reason:document.getElementById('reasonInput').value}),'btn');} const copy=document.getElementById('copyClaim');if(copy)copy.onclick=()=>navigator.clipboard.writeText(claim.claim_id);const cu=document.getElementById('copyUrls');if(cu)cu.onclick=()=>navigator.clipboard.writeText(urls.join('\n'));const of=document.getElementById('openFirst');if(of)of.onclick=()=>window.open(urls[0],'_blank');const ccu=document.getElementById('copyCheckoutUrl');if(ccu)ccu.onclick=()=>navigator.clipboard.writeText(ccu.dataset.url||'');document.querySelectorAll('.copy-url').forEach((btn)=>{btn.onclick=()=>navigator.clipboard.writeText(btn.dataset.url||'');}); } const status=(m)=>document.getElementById('status').textContent=m; diff --git a/tests/api-payments.test.js b/tests/api-payments.test.js index 7398af3..59b2dbb 100644 --- a/tests/api-payments.test.js +++ b/tests/api-payments.test.js @@ -44,7 +44,7 @@ test('pk_ STRIPE_SECRET_KEY returns STRIPE_SECRET_KEY_INVALID', async()=>{proces test('unauthorized admin checkout rejected', async()=>{process.env.STRIPE_SECRET_KEY='sk';process.env.ADMIN_API_KEY='k'; const h=loadCheckout(async()=>[],StripeMock); const r=makeRes(); await h({method:'POST',headers:{},body:{claimId:'clm_1'}},r); assert.equal(r.statusCode,401);}); test('claim not cards_published rejected', async()=>{process.env.STRIPE_SECRET_KEY='sk';process.env.ADMIN_API_KEY='k'; const h=loadCheckout(async(q)=>String(q).includes('from claim_requests')?[{claim_id:'clm_1',status:'approved'}]:[],StripeMock); const r=makeRes(); await h({method:'POST',headers:{authorization:'Bearer k'},body:{claimId:'clm_1'}},r); assert.equal(r.body.status,'CLAIM_NOT_READY_FOR_PAYMENT');}); test('stripe session creation failure does not update claim status', async()=>{process.env.STRIPE_SECRET_KEY='sk';process.env.ADMIN_API_KEY='k'; const calls=[]; const h=loadCheckout(async(q)=>{calls.push(String(q)); if(String(q).includes('from claim_requests')) return [{claim_id:'clm_1',status:'cards_published',tenant:'commandlayer',pack_id:'founding'}]; return [];},function(){return{checkout:{sessions:{create:async()=>{const e=new Error('boom');e.code='api_error';throw e;}}}}}); const r=makeRes(); await h({method:'POST',headers:{authorization:'Bearer k'},body:{claimId:'clm_1'}},r); assert.equal(r.body.status,'CHECKOUT_SESSION_CREATE_FAILED'); assert.equal(calls.some(c=>c.includes("set status = 'payment_pending'")),false);}); -test('cards_published claim creates checkout and returns checkoutUrl', async()=>{process.env.STRIPE_SECRET_KEY='sk';process.env.ADMIN_API_KEY='k'; const calls=[]; const h=loadCheckout(async(q,p)=>{calls.push(String(q)); if(String(q).includes('from claim_requests')) return [{claim_id:'clm_1',status:'cards_published',tenant:'commandlayer',pack_id:'founding'}]; return [];},StripeMock); const r=makeRes(); await h({method:'POST',headers:{authorization:'Bearer k'},body:{claimId:'clm_1'}},r); assert.equal(r.body.status,'CHECKOUT_SESSION_CREATED'); assert.equal(r.body.checkoutUrl,'https://checkout.stripe.test/cs_1'); assert.ok(calls.some(c=>c.includes("set status = 'payment_pending'"))); assert.ok(calls.some(c=>c.includes('payment.checkout_created'))); assert.ok(calls.some(c=>c.includes('cards_published')&&c.includes('payment_pending')));}); +test('cards_published claim creates checkout and returns checkoutUrl', async()=>{process.env.STRIPE_SECRET_KEY='sk';process.env.ADMIN_API_KEY='k'; const calls=[]; const h=loadCheckout(async(q,p)=>{calls.push(String(q)); if(String(q).includes('from claim_requests')) return [{claim_id:'clm_1',status:'cards_published',tenant:'commandlayer',pack_id:'founding'}]; return [];},StripeMock); const r=makeRes(); await h({method:'POST',headers:{authorization:'Bearer k'},body:{claimId:'clm_1'}},r); assert.equal(r.body.status,'CHECKOUT_SESSION_CREATED'); assert.equal(r.body.checkoutUrl,'https://checkout.stripe.test/cs_1'); assert.ok(calls.some(c=>c.includes("set status = 'payment_pending'"))); assert.ok(calls.some(c=>c.includes('insert into claim_events'))); assert.ok(calls.some(c=>c.includes('cards_published')&&c.includes('payment_pending')));}); test('missing COMMANDLAYER_SITE_URL defaults to https://www.commandlayer.org', async()=>{process.env.STRIPE_SECRET_KEY='sk';process.env.ADMIN_API_KEY='k'; delete process.env.COMMANDLAYER_SITE_URL; let payload=null; const h=loadCheckout(async(q)=>String(q).includes('from claim_requests')?[{claim_id:'clm_1',status:'cards_published',tenant:'commandlayer',pack_id:'founding'}]:[],createStripeRecorder((p)=>{payload=p;})); const r=makeRes(); await h({method:'POST',headers:{authorization:'Bearer k'},body:{claimId:'clm_1'}},r); assert.equal(r.statusCode,200); assert.equal(payload.success_url,'https://www.commandlayer.org/claim/status.html?claimId=clm_1&payment=success'); assert.equal(payload.cancel_url,'https://www.commandlayer.org/claim/status.html?claimId=clm_1&payment=cancelled');}); @@ -61,3 +61,10 @@ test('webhook invalid signature rejected', async()=>{process.env.STRIPE_SECRET_K test('checkout.session.completed marks claim paid + idempotent', async()=>{process.env.STRIPE_SECRET_KEY='sk';process.env.STRIPE_WEBHOOK_SECRET='wh'; let paid=false; const calls=[]; const Stripe=function(){return{webhooks:{constructEvent:()=>({type:'checkout.session.completed',data:{object:{id:'cs_1',payment_intent:'pi_1',metadata:{claimId:'clm_1'}}}})}}}; const h=loadWebhook(async(q)=>{calls.push(String(q)); if(String(q).includes('from claim_requests')) return [paid?{claim_id:'clm_1',status:'paid',payment_status:'paid'}:{claim_id:'clm_1',status:'payment_pending'}]; if(String(q).includes("set status = 'paid'")) paid=true; return [];},Stripe); let r=makeRes(); await h({method:'POST',headers:{'stripe-signature':'x'},body:'{}'},r); assert.equal(r.body.ok,true); assert.ok(calls.some(c=>c.includes('payment.completed'))); r=makeRes(); await h({method:'POST',headers:{'stripe-signature':'x'},body:'{}'},r); assert.equal(r.body.ok,true); }); + + +test('payment_pending without forceNew returns existing checkout', async()=>{process.env.STRIPE_SECRET_KEY='sk';process.env.ADMIN_API_KEY='k'; process.env.COMMANDLAYER_SITE_URL='https://www.commandlayer.org'; let sessionCreateCalled=false; const h=loadCheckout(async(q)=>String(q).includes('from claim_requests')?[{claim_id:'clm_1',status:'payment_pending',stripe_checkout_session_id:'cs_existing',stripe_checkout_url:'https://checkout.stripe.test/existing'}]:[],function(){return{checkout:{sessions:{create:async()=>{sessionCreateCalled=true;return {id:'cs_new',url:'https://checkout.stripe.test/cs_new'};}}}};}); const r=makeRes(); await h({method:'POST',headers:{authorization:'Bearer k'},body:{claimId:'clm_1'}},r); assert.equal(r.statusCode,200); assert.equal(r.body.status,'CHECKOUT_SESSION_CREATED'); assert.equal(r.body.sessionId,'cs_existing'); assert.equal(sessionCreateCalled,false);}); + +test('payment_pending with forceNew creates a new Stripe session + regenerated event + no duplicate transition', async()=>{process.env.STRIPE_SECRET_KEY='sk';process.env.ADMIN_API_KEY='k'; process.env.COMMANDLAYER_SITE_URL='https://www.commandlayer.org'; const calls=[]; const h=loadCheckout(async(q)=>{calls.push(String(q)); if(String(q).includes('from claim_requests')) return [{claim_id:'clm_1',status:'payment_pending',tenant:'commandlayer',pack_id:'founding',stripe_checkout_session_id:'cs_old'}]; return [];},StripeMock); const r=makeRes(); await h({method:'POST',headers:{authorization:'Bearer k'},body:{claimId:'clm_1',forceNew:true}},r); assert.equal(r.statusCode,200); assert.equal(r.body.status,'CHECKOUT_SESSION_REGENERATED'); assert.ok(calls.some(c=>c.includes('insert into claim_events'))); assert.equal(calls.some(c=>c.includes('cards_published')&&c.includes('payment_pending')),false);}); + +test('paid claim cannot regenerate checkout', async()=>{process.env.STRIPE_SECRET_KEY='sk';process.env.ADMIN_API_KEY='k'; process.env.COMMANDLAYER_SITE_URL='https://www.commandlayer.org'; const h=loadCheckout(async(q)=>String(q).includes('from claim_requests')?[{claim_id:'clm_1',status:'paid',payment_status:'paid'}]:[],StripeMock); const r=makeRes(); await h({method:'POST',headers:{authorization:'Bearer k'},body:{claimId:'clm_1',forceNew:true}},r); assert.equal(r.statusCode,409); assert.equal(r.body.status,'PAYMENT_ALREADY_COMPLETED');});