Skip to content

Commit 014a036

Browse files
markgohoclaude
andauthored
feat: handle Stripe refunds via webhook and admin UI (#62)
* feat: handle Stripe refunds via webhook and admin UI Add full refund handling for both Stripe Dashboard-initiated refunds (charge.refunded webhook) and admin-initiated refunds (POST endpoint). When a refund occurs: Stripe subscription is canceled, membership is deactivated, Hugo profile is drafted, and newsletter is unsubscribed. Uses state-based idempotency to prevent double-processing. Manual step required: add charge.refunded to Stripe webhook events. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: improve error handling for Stripe refund processing cancelStripeSubscription now throws on failure instead of returning false, preventing billing integrity issues where a member could be deactivated while their Stripe subscription remains active. processRefundActions now throws on critical Firestore deactivation failure so Stripe retries the webhook. Added Firestore error handling to findMemberByStripeCustomer, fixed misleading docstring about pre-marking, and added missing HttpError test coverage for the webhook refund path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add missing processChargeRefunded mock to handler integration test The handler.test.ts mock was missing the processChargeRefunded method added to StripeWebhookService, causing typecheck:tests to fail in CI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dc6d906 commit 014a036

30 files changed

Lines changed: 1480 additions & 6 deletions

functions/src/admin-members-api/plugins/admin-members-plugin.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Elysia } from "elysia";
22
import { logger as firebaseLogger } from "firebase-functions/v2";
33
import { AuthService } from "../../shared-api/services/auth/index.js";
4+
import { EmailService } from "../../shared-api/services/email/index.js";
45
import { adminDerive } from "../../shared-api/utils/admin-derive.js";
56
import { adminGuard } from "../../shared-api/utils/admin-guard.js";
67
import { getAdminUid } from "../../shared-api/utils/get-admin-uid.js";
@@ -11,6 +12,7 @@ import {
1112
extendMembershipLogic,
1213
getMemberLogic,
1314
listMembersLogic,
15+
refundMembershipLogic,
1416
updateClaimsLogic,
1517
updateMemberLogic,
1618
} from "../routes/index.js";
@@ -24,6 +26,8 @@ import {
2426
GetMemberApiResponseSchema,
2527
ListMembersApiResponseSchema,
2628
MemberIdParameterSchema,
29+
RefundMembershipApiResponseSchema,
30+
RefundMembershipBodySchema,
2731
UpdateClaimsApiResponseSchema,
2832
UpdateClaimsBodySchema,
2933
UpdateMemberApiResponseSchema,
@@ -55,6 +59,10 @@ export function createAdminMembersPlugin(services?: PartialServices) {
5559
services?.memberAdminService ?? MemberAdminService,
5660
)
5761
.decorate(SERVICE_KEYS.AUTH_SERVICE, services?.authService ?? AuthService)
62+
.decorate(
63+
SERVICE_KEYS.EMAIL_SERVICE,
64+
services?.emailService ?? EmailService,
65+
)
5866
.decorate(SERVICE_KEYS.LOGGER, services?.logger ?? firebaseLogger)
5967
.derive(adminDerive)
6068
.onBeforeHandle({ as: "local" }, adminGuard)
@@ -214,6 +222,36 @@ export function createAdminMembersPlugin(services?: PartialServices) {
214222
body: ExtendMembershipBodySchema,
215223
response: ExtendMembershipApiResponseSchema,
216224
},
225+
)
226+
// POST /:memberId/membership/refund (served at /api/admin/members/:memberId/membership/refund)
227+
.post(
228+
"/refund",
229+
async ({
230+
params,
231+
body,
232+
adminToken,
233+
memberAdminService,
234+
emailService,
235+
logger,
236+
set,
237+
}) => {
238+
const typedBody = body as { reason?: string } | undefined;
239+
return refundMembershipLogic({
240+
memberId: params.memberId,
241+
...(typedBody?.reason !== undefined && {
242+
reason: typedBody.reason,
243+
}),
244+
adminUid: getAdminUid(adminToken, logger),
245+
memberAdminService,
246+
emailService,
247+
logger,
248+
set,
249+
});
250+
},
251+
{
252+
body: RefundMembershipBodySchema,
253+
response: RefundMembershipApiResponseSchema,
254+
},
217255
),
218256
)
219257
// PATCH /:memberId/claims - Update custom claims (served at /api/admin/members/:memberId/claims)

functions/src/admin-members-api/routes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export { deleteUserLogic } from "./delete-user.js";
44
export { extendMembershipLogic } from "./extend-membership.js";
55
export { getMemberLogic } from "./get-member.js";
66
export { listMembersLogic } from "./list-members.js";
7+
export { refundMembershipLogic } from "./refund-membership.js";
78
export { updateClaimsLogic } from "./update-claims.js";
89
export { updateMemberLogic } from "./update-member.js";
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { describe, expect, it, mock } from "bun:test";
2+
import { Timestamp } from "firebase-admin/firestore";
3+
import {
4+
NotFoundError,
5+
ValidationError,
6+
} from "../../shared-api/errors/http-error.js";
7+
import { handleRequest } from "../../test-utils/handle-request.js";
8+
import type { MemberDocument } from "../../types/member-document.js";
9+
import type { RefundMembershipResult } from "../services/refund-membership.js";
10+
import { createAdminTestPlugin } from "../test-utils/create-admin-test-plugin.js";
11+
12+
/**
13+
* Tests for POST /:memberId/membership/refund.
14+
*
15+
* Uses createAdminTestPlugin() factory with mocked services.
16+
*/
17+
describe("POST /:memberId/membership/refund", () => {
18+
interface SetupOptions {
19+
// Request parameters
20+
memberId?: string;
21+
authToken?: string | null;
22+
body?: Record<string, unknown>;
23+
24+
// Scenario flags
25+
memberNotFound?: boolean;
26+
noStripeData?: boolean;
27+
stripeApiError?: boolean;
28+
refundResult?: RefundMembershipResult;
29+
}
30+
31+
function setup({
32+
memberId = "test-id",
33+
authToken = "admin-token",
34+
body = {},
35+
memberNotFound = false,
36+
noStripeData = false,
37+
stripeApiError = false,
38+
refundResult,
39+
}: SetupOptions = {}) {
40+
const defaultMember: MemberDocument = {
41+
uid: "test-id",
42+
email: "test@example.com",
43+
createdAt: Timestamp.now(),
44+
membershipActive: false,
45+
subscriptionStatus: "refunded",
46+
stripeCustomerId: "cus_test",
47+
stripeSubscriptionId: "sub_test",
48+
refundedAt: Timestamp.now(),
49+
};
50+
51+
const defaultResult: RefundMembershipResult = {
52+
member: defaultMember,
53+
stripeRefundCreated: true,
54+
subscriptionCanceled: true,
55+
refundActions: {
56+
memberDeactivated: true,
57+
},
58+
};
59+
60+
const mockRefundMembership = mock((): Promise<RefundMembershipResult> => {
61+
if (memberNotFound) {
62+
return Promise.reject(new NotFoundError("Member not found"));
63+
}
64+
if (noStripeData) {
65+
return Promise.reject(
66+
new ValidationError(
67+
"Member does not have Stripe subscription data. Use manual deactivation instead.",
68+
),
69+
);
70+
}
71+
if (stripeApiError) {
72+
return Promise.reject(new Error("Stripe API error"));
73+
}
74+
return Promise.resolve(refundResult ?? defaultResult);
75+
});
76+
77+
const testApp = createAdminTestPlugin({
78+
memberAdminService: {
79+
refundMembership: mockRefundMembership,
80+
},
81+
});
82+
83+
// Build request from parameters
84+
const headers: Record<string, string> = {
85+
"Content-Type": "application/json",
86+
};
87+
if (authToken) {
88+
headers["Authorization"] = `Bearer ${authToken}`;
89+
}
90+
91+
const request = new Request(
92+
`http://localhost/${memberId}/membership/refund`,
93+
{
94+
method: "POST",
95+
headers,
96+
body: JSON.stringify(body),
97+
},
98+
);
99+
100+
return { testApp, request };
101+
}
102+
103+
describe("Authentication", () => {
104+
it("should return 401 when no authorization header is provided", async () => {
105+
const { testApp, request } = setup({ authToken: null });
106+
107+
const response = await handleRequest(testApp, request);
108+
109+
expect(response.status).toBe(401);
110+
});
111+
112+
it("should return 403 when non-admin tries to refund", async () => {
113+
const { testApp, request } = setup({ authToken: "non-admin-token" });
114+
115+
const response = await handleRequest(testApp, request);
116+
117+
expect(response.status).toBe(403);
118+
});
119+
});
120+
121+
describe("Successful refund", () => {
122+
it("should refund membership successfully", async () => {
123+
const { testApp, request } = setup();
124+
125+
const response = await handleRequest(testApp, request);
126+
127+
expect(response.status).toBe(200);
128+
const responseBody = (await response.json()) as {
129+
success?: boolean;
130+
refundResult?: {
131+
stripeRefundCreated?: boolean;
132+
subscriptionCanceled?: boolean;
133+
memberDeactivated?: boolean;
134+
};
135+
};
136+
expect(responseBody.success).toBe(true);
137+
expect(responseBody.refundResult?.stripeRefundCreated).toBe(true);
138+
expect(responseBody.refundResult?.subscriptionCanceled).toBe(true);
139+
expect(responseBody.refundResult?.memberDeactivated).toBe(true);
140+
});
141+
142+
it("should accept optional reason in body", async () => {
143+
const { testApp, request } = setup({
144+
body: { reason: "Customer requested refund" },
145+
});
146+
147+
const response = await handleRequest(testApp, request);
148+
149+
expect(response.status).toBe(200);
150+
});
151+
152+
it("should include warning when non-critical actions fail", async () => {
153+
const { testApp, request } = setup({
154+
refundResult: {
155+
member: {
156+
uid: "test-id",
157+
email: "test@example.com",
158+
createdAt: Timestamp.now(),
159+
membershipActive: false,
160+
subscriptionStatus: "refunded",
161+
refundedAt: Timestamp.now(),
162+
},
163+
stripeRefundCreated: true,
164+
subscriptionCanceled: true,
165+
refundActions: {
166+
memberDeactivated: true,
167+
profileDrafted: false,
168+
warning: "Non-critical actions failed: Draft profile failed",
169+
},
170+
},
171+
});
172+
173+
const response = await handleRequest(testApp, request);
174+
175+
expect(response.status).toBe(200);
176+
const responseBody = (await response.json()) as {
177+
success?: boolean;
178+
refundResult?: { warning?: string };
179+
};
180+
expect(responseBody.success).toBe(true);
181+
expect(responseBody.refundResult?.warning).toContain(
182+
"Non-critical actions failed",
183+
);
184+
});
185+
});
186+
187+
describe("Error handling", () => {
188+
it("should return 404 for non-existent member", async () => {
189+
const { testApp, request } = setup({ memberNotFound: true });
190+
191+
const response = await handleRequest(testApp, request);
192+
193+
expect(response.status).toBe(404);
194+
});
195+
196+
it("should return 400 when member has no Stripe data", async () => {
197+
const { testApp, request } = setup({ noStripeData: true });
198+
199+
const response = await handleRequest(testApp, request);
200+
201+
expect(response.status).toBe(400);
202+
const responseBody = (await response.json()) as { error?: string };
203+
expect(responseBody.error).toContain("Stripe subscription data");
204+
});
205+
206+
it("should return 500 for Stripe API errors", async () => {
207+
const { testApp, request } = setup({ stripeApiError: true });
208+
209+
const response = await handleRequest(testApp, request);
210+
211+
expect(response.status).toBe(500);
212+
});
213+
});
214+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { ERROR_IDS } from "../../constants/error-ids.js";
2+
import type { EmailServiceInterface } from "../../shared-api/services/email/index.js";
3+
import type { Logger } from "../../shared-api/types/logger.js";
4+
import { handleRouteError } from "../../shared-api/utils/route-error-handler.js";
5+
import type { RefundMembershipApiResponse } from "../schemas/member-schemas.js";
6+
import { toMemberResponse } from "../schemas/member-schemas.js";
7+
import type { MemberAdminService } from "../services/interface.js";
8+
9+
/**
10+
* Route handler for POST /:memberId/membership/refund.
11+
*
12+
* Custom handler (not factory) since refund returns a richer result
13+
* than just a MemberDocument wrapped in MemberSuccessResponse.
14+
*/
15+
export async function refundMembershipLogic({
16+
memberId,
17+
reason,
18+
adminUid,
19+
memberAdminService,
20+
emailService,
21+
logger,
22+
set,
23+
}: {
24+
memberId: string;
25+
reason?: string;
26+
adminUid: string;
27+
memberAdminService: MemberAdminService;
28+
emailService?: EmailServiceInterface;
29+
logger: Logger;
30+
set: { status?: number | string };
31+
}): Promise<RefundMembershipApiResponse> {
32+
try {
33+
const result = await memberAdminService.refundMembership({
34+
memberId,
35+
...(reason !== undefined && { reason }),
36+
...(emailService !== undefined && { emailService }),
37+
});
38+
39+
logger.info("Admin refunded membership", {
40+
adminUid,
41+
targetMemberId: memberId,
42+
stripeRefundCreated: result.stripeRefundCreated,
43+
subscriptionCanceled: result.subscriptionCanceled,
44+
memberDeactivated: result.refundActions.memberDeactivated,
45+
});
46+
47+
const isAdmin = await memberAdminService.isAdmin(memberId, logger);
48+
49+
return {
50+
success: true,
51+
member: toMemberResponse(result.member, isAdmin),
52+
refundResult: {
53+
stripeRefundCreated: result.stripeRefundCreated,
54+
subscriptionCanceled: result.subscriptionCanceled,
55+
memberDeactivated: result.refundActions.memberDeactivated,
56+
...(result.refundActions.profileDrafted !== undefined && {
57+
profileDrafted: result.refundActions.profileDrafted,
58+
}),
59+
...(result.refundActions.newsletterUnsubscribed !== undefined && {
60+
newsletterUnsubscribed: result.refundActions.newsletterUnsubscribed,
61+
}),
62+
...(result.refundActions.warning !== undefined && {
63+
warning: result.refundActions.warning,
64+
}),
65+
},
66+
};
67+
} catch (error) {
68+
return handleRouteError({
69+
error,
70+
operation: "refund membership",
71+
errorId: ERROR_IDS.API_ADMIN_REFUND_MEMBERSHIP_FAILED,
72+
logger,
73+
set,
74+
context: { memberId, adminUid },
75+
});
76+
}
77+
}

0 commit comments

Comments
 (0)