Skip to content

Commit 6e57d67

Browse files
authored
Merge pull request #359 from commandlayer/codex/add-internal-endpoint-to-mark-claims-as-paid
Add internal authenticated endpoint to mark claims paid
2 parents acc239d + 2e804fb commit 6e57d67

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

api/internal/claims/mark-paid.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
'use strict';
2+
3+
const db = require('../../../lib/db');
4+
5+
function logSafe(message, code, claimId, provider) {
6+
console.log(JSON.stringify({ message, code, claimId: claimId || null, provider: provider || null }));
7+
}
8+
9+
module.exports = async function handler(req, res) {
10+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
11+
res.setHeader('Cache-Control', 'no-store');
12+
13+
if (req.method !== 'POST') {
14+
res.setHeader('Allow', 'POST');
15+
return res.status(405).json({ ok: false, status: 'METHOD_NOT_ALLOWED' });
16+
}
17+
18+
const sharedSecret = process.env.COMMERCIAL_WEBHOOK_SHARED_SECRET;
19+
if (!sharedSecret) {
20+
logSafe('Internal payment confirmation endpoint not configured', 'INTERNAL_PAYMENT_CONFIRMATION_NOT_CONFIGURED');
21+
return res.status(503).json({ ok: false, status: 'INTERNAL_PAYMENT_CONFIRMATION_NOT_CONFIGURED' });
22+
}
23+
24+
const authHeader = req.headers.authorization || req.headers.Authorization;
25+
const expected = `Bearer ${sharedSecret}`;
26+
if (authHeader !== expected) {
27+
logSafe('Unauthorized internal payment confirmation request', 'UNAUTHORIZED');
28+
return res.status(401).json({ ok: false, status: 'UNAUTHORIZED' });
29+
}
30+
31+
const body = req.body || {};
32+
const claimId = body.claimId;
33+
const provider = body.provider;
34+
const providerPaymentId = body.providerPaymentId;
35+
const paymentIntentId = body.paymentIntentId || null;
36+
const amountCents = body.amountCents;
37+
const currency = (body.currency || 'usd').toLowerCase();
38+
39+
if (!claimId) return res.status(400).json({ ok: false, status: 'CLAIM_ID_REQUIRED' });
40+
if (!provider) return res.status(400).json({ ok: false, status: 'PROVIDER_REQUIRED' });
41+
if (provider !== 'stripe') return res.status(400).json({ ok: false, status: 'PROVIDER_NOT_SUPPORTED' });
42+
if (!providerPaymentId) return res.status(400).json({ ok: false, status: 'PROVIDER_PAYMENT_ID_REQUIRED' });
43+
if (!Number.isInteger(amountCents) || amountCents <= 0) return res.status(400).json({ ok: false, status: 'INVALID_AMOUNT_CENTS' });
44+
45+
try {
46+
const claimResult = await db.query('select claim_id, status from claim_requests where claim_id = $1 limit 1', [claimId]);
47+
const claim = claimResult.rows[0];
48+
if (!claim) return res.status(404).json({ ok: false, status: 'CLAIM_NOT_FOUND' });
49+
50+
if (claim.status !== 'payment_pending' && claim.status !== 'paid') {
51+
return res.status(400).json({ ok: false, status: 'CLAIM_NOT_READY_FOR_PAYMENT' });
52+
}
53+
54+
await db.query(
55+
`update claim_payments
56+
set status = 'paid', provider_payment_id = $3, payment_intent_id = $4, amount_cents = $5, currency = $6, updated_at = now()
57+
where claim_id = $1 and provider = $2`,
58+
[claimId, provider, providerPaymentId, paymentIntentId, amountCents, currency]
59+
);
60+
61+
await db.query(
62+
`update claim_payments
63+
set status = 'paid', claim_id = $1, provider = $2, payment_intent_id = $4, amount_cents = $5, currency = $6, updated_at = now()
64+
where provider_payment_id = $3`,
65+
[claimId, provider, providerPaymentId, paymentIntentId, amountCents, currency]
66+
);
67+
68+
if (claim.status === 'paid') {
69+
logSafe('Claim already paid; returning idempotent success', 'CLAIM_MARKED_PAID', claimId, provider);
70+
return res.status(200).json({ ok: true, status: 'CLAIM_MARKED_PAID', claimId });
71+
}
72+
73+
await db.query(
74+
`update claim_requests
75+
set status = 'paid', payment_status = 'paid', payment_amount_cents = $2,
76+
payment_currency = $3, stripe_checkout_session_id = $4,
77+
stripe_payment_intent_id = $5, paid_at = now(), updated_at = now()
78+
where claim_id = $1`,
79+
[claimId, amountCents, currency, providerPaymentId, paymentIntentId]
80+
);
81+
82+
await db.query(
83+
`insert into claim_events (claim_id, event_type, message, metadata_json)
84+
values ($1, 'payment.completed', 'Payment completed.', $2::jsonb)`,
85+
[claimId, JSON.stringify({ provider })]
86+
);
87+
88+
const transitionExists = await db.query(
89+
`select 1 from claim_status_transitions
90+
where claim_id = $1 and from_status = 'payment_pending' and to_status = 'paid'
91+
limit 1`,
92+
[claimId]
93+
);
94+
if (!transitionExists.rows.length) {
95+
await db.query(
96+
`insert into claim_status_transitions (claim_id, from_status, to_status)
97+
values ($1, 'payment_pending', 'paid')`,
98+
[claimId]
99+
);
100+
}
101+
102+
logSafe('Claim marked paid', 'CLAIM_MARKED_PAID', claimId, provider);
103+
return res.status(200).json({ ok: true, status: 'CLAIM_MARKED_PAID', claimId });
104+
} catch (error) {
105+
logSafe('Failed to confirm claim payment', 'PAYMENT_CONFIRMATION_FAILED', claimId, provider);
106+
return res.status(500).json({ ok: false, status: 'PAYMENT_CONFIRMATION_FAILED' });
107+
}
108+
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert/strict');
5+
6+
const db = require('../lib/db');
7+
const handler = require('../api/internal/claims/mark-paid');
8+
9+
function makeRes() { return { statusCode: 200, headers: {}, body: null, setHeader(n, v) { this.headers[n.toLowerCase()] = v; }, status(c) { this.statusCode = c; return this; }, json(p) { this.body = p; return this; } }; }
10+
11+
function makeReq(body, auth) { return { method: 'POST', headers: auth ? { authorization: auth } : {}, body }; }
12+
13+
test('missing shared secret env returns 503', async () => {
14+
delete process.env.COMMERCIAL_WEBHOOK_SHARED_SECRET;
15+
const res = makeRes();
16+
await handler(makeReq({}, null), res);
17+
assert.equal(res.statusCode, 503);
18+
assert.equal(res.body.status, 'INTERNAL_PAYMENT_CONFIRMATION_NOT_CONFIGURED');
19+
});
20+
21+
test('invalid auth returns 401', async () => {
22+
process.env.COMMERCIAL_WEBHOOK_SHARED_SECRET = 'top-secret';
23+
const res = makeRes();
24+
await handler(makeReq({}, 'Bearer nope'), res);
25+
assert.equal(res.statusCode, 401);
26+
assert.equal(res.body.status, 'UNAUTHORIZED');
27+
});
28+
29+
test('missing claimId rejected', async () => {
30+
process.env.COMMERCIAL_WEBHOOK_SHARED_SECRET = 'top-secret';
31+
const res = makeRes();
32+
await handler(makeReq({ provider: 'stripe', providerPaymentId: 'cs_1', amountCents: 1 }, 'Bearer top-secret'), res);
33+
assert.equal(res.statusCode, 400);
34+
assert.equal(res.body.status, 'CLAIM_ID_REQUIRED');
35+
});
36+
37+
test('payment_pending claim becomes paid', async () => {
38+
process.env.COMMERCIAL_WEBHOOK_SHARED_SECRET = 'top-secret';
39+
const queries = [];
40+
db.query = async (q) => {
41+
queries.push(q);
42+
if (q.includes('from claim_requests')) return { rows: [{ claim_id: 'clm_1', status: 'payment_pending' }] };
43+
if (q.includes('from claim_status_transitions')) return { rows: [] };
44+
return { rows: [] };
45+
};
46+
const res = makeRes();
47+
await handler(makeReq({ claimId: 'clm_1', provider: 'stripe', providerPaymentId: 'cs_1', paymentIntentId: 'pi_1', amountCents: 2000, currency: 'usd' }, 'Bearer top-secret'), res);
48+
assert.equal(res.statusCode, 200);
49+
assert.equal(res.body.status, 'CLAIM_MARKED_PAID');
50+
assert.ok(queries.some((q) => q.includes('update claim_requests')));
51+
});
52+
53+
test('already paid claim is idempotent', async () => {
54+
process.env.COMMERCIAL_WEBHOOK_SHARED_SECRET = 'top-secret';
55+
const queries = [];
56+
db.query = async (q) => {
57+
queries.push(q);
58+
if (q.includes('from claim_requests')) return { rows: [{ claim_id: 'clm_2', status: 'paid' }] };
59+
return { rows: [] };
60+
};
61+
const res = makeRes();
62+
await handler(makeReq({ claimId: 'clm_2', provider: 'stripe', providerPaymentId: 'cs_2', amountCents: 2000 }, 'Bearer top-secret'), res);
63+
assert.equal(res.statusCode, 200);
64+
assert.equal(res.body.status, 'CLAIM_MARKED_PAID');
65+
assert.equal(queries.some((q) => q.includes('insert into claim_status_transitions')), false);
66+
});
67+
68+
test('non-payment_pending claim rejected', async () => {
69+
process.env.COMMERCIAL_WEBHOOK_SHARED_SECRET = 'top-secret';
70+
db.query = async (q) => (q.includes('from claim_requests') ? { rows: [{ claim_id: 'clm_3', status: 'created' }] } : { rows: [] });
71+
const res = makeRes();
72+
await handler(makeReq({ claimId: 'clm_3', provider: 'stripe', providerPaymentId: 'cs_3', amountCents: 2000 }, 'Bearer top-secret'), res);
73+
assert.equal(res.statusCode, 400);
74+
assert.equal(res.body.status, 'CLAIM_NOT_READY_FOR_PAYMENT');
75+
});
76+
77+
test('payment.completed event written', async () => {
78+
process.env.COMMERCIAL_WEBHOOK_SHARED_SECRET = 'top-secret';
79+
const queries = [];
80+
db.query = async (q) => {
81+
queries.push(q);
82+
if (q.includes('from claim_requests')) return { rows: [{ claim_id: 'clm_4', status: 'payment_pending' }] };
83+
if (q.includes('from claim_status_transitions')) return { rows: [] };
84+
return { rows: [] };
85+
};
86+
const res = makeRes();
87+
await handler(makeReq({ claimId: 'clm_4', provider: 'stripe', providerPaymentId: 'cs_4', amountCents: 2000 }, 'Bearer top-secret'), res);
88+
assert.equal(res.statusCode, 200);
89+
assert.ok(queries.some((q) => q.includes("insert into claim_events")));
90+
});
91+
92+
test('payment_pending -> paid transition written', async () => {
93+
process.env.COMMERCIAL_WEBHOOK_SHARED_SECRET = 'top-secret';
94+
const queries = [];
95+
db.query = async (q) => {
96+
queries.push(q);
97+
if (q.includes('from claim_requests')) return { rows: [{ claim_id: 'clm_5', status: 'payment_pending' }] };
98+
if (q.includes('from claim_status_transitions')) return { rows: [] };
99+
return { rows: [] };
100+
};
101+
const res = makeRes();
102+
await handler(makeReq({ claimId: 'clm_5', provider: 'stripe', providerPaymentId: 'cs_5', amountCents: 2000 }, 'Bearer top-secret'), res);
103+
assert.equal(res.statusCode, 200);
104+
assert.ok(queries.some((q) => q.includes('insert into claim_status_transitions')));
105+
});

0 commit comments

Comments
 (0)