Skip to content

Commit 7198697

Browse files
authored
Add DELETE path for stripe links (#169)
Add the API routes to deactivate stripe links.
1 parent a3e417e commit 7198697

File tree

4 files changed

+177
-5
lines changed

4 files changed

+177
-5
lines changed

src/api/routes/stripe.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import {
2323
DatabaseFetchError,
2424
DatabaseInsertError,
2525
InternalServerError,
26+
NotFoundError,
2627
UnauthenticatedError,
28+
UnauthorizedError,
2729
ValidationError,
2830
} from "common/errors/index.js";
2931
import { Modules } from "common/modules.js";
@@ -39,6 +41,7 @@ import stripe, { Stripe } from "stripe";
3941
import rawbody from "fastify-raw-body";
4042
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
4143
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
44+
import { z } from "zod";
4245

4346
const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
4447
await fastify.register(rawbody, {
@@ -177,6 +180,112 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
177180
reply.status(201).send({ id: linkId, link: url });
178181
},
179182
);
183+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().delete(
184+
"/paymentLinks/:linkId",
185+
{
186+
schema: withRoles(
187+
[AppRoles.STRIPE_LINK_CREATOR],
188+
withTags(["Stripe"], {
189+
summary: "Deactivate a Stripe payment link.",
190+
params: z.object({
191+
linkId: z.string().min(1).openapi({
192+
description: "Payment Link ID",
193+
example: "plink_abc123",
194+
}),
195+
}),
196+
}),
197+
),
198+
onRequest: fastify.authorizeFromSchema,
199+
},
200+
async (request, reply) => {
201+
if (!request.username) {
202+
throw new UnauthenticatedError({ message: "No username found" });
203+
}
204+
const { linkId } = request.params;
205+
const response = await fastify.dynamoClient.send(
206+
new QueryCommand({
207+
TableName: genericConfig.StripeLinksDynamoTableName,
208+
IndexName: "LinkIdIndex",
209+
KeyConditionExpression: "linkId = :linkId",
210+
ExpressionAttributeValues: {
211+
":linkId": { S: linkId },
212+
},
213+
}),
214+
);
215+
if (!response) {
216+
throw new DatabaseFetchError({
217+
message: "Could not check for payment link in table.",
218+
});
219+
}
220+
if (!response.Items || response.Items?.length !== 1) {
221+
throw new NotFoundError({ endpointName: request.url });
222+
}
223+
const unmarshalledEntry = unmarshall(response.Items[0]) as {
224+
userId: string;
225+
invoiceId: string;
226+
amount?: number;
227+
priceId?: string;
228+
productId?: string;
229+
};
230+
if (
231+
unmarshalledEntry.userId !== request.username &&
232+
!request.userRoles?.has(AppRoles.BYPASS_OBJECT_LEVEL_AUTH)
233+
) {
234+
throw new UnauthorizedError({
235+
message: "Not authorized to deactivate this payment link.",
236+
});
237+
}
238+
const logStatement = buildAuditLogTransactPut({
239+
entry: {
240+
module: Modules.STRIPE,
241+
actor: request.username,
242+
target: `Link ${linkId} | Invoice ${unmarshalledEntry.invoiceId}`,
243+
message: "Deactivated Stripe payment link",
244+
},
245+
});
246+
const dynamoCommand = new TransactWriteItemsCommand({
247+
TransactItems: [
248+
logStatement,
249+
{
250+
Update: {
251+
TableName: genericConfig.StripeLinksDynamoTableName,
252+
Key: {
253+
userId: { S: unmarshalledEntry.userId },
254+
linkId: { S: linkId },
255+
},
256+
UpdateExpression: "SET active = :new_val",
257+
ConditionExpression: "active = :old_val",
258+
ExpressionAttributeValues: {
259+
":new_val": { BOOL: false },
260+
":old_val": { BOOL: true },
261+
},
262+
},
263+
},
264+
],
265+
});
266+
const secretApiConfig =
267+
(await getSecretValue(
268+
fastify.secretsManagerClient,
269+
genericConfig.ConfigSecretName,
270+
)) || {};
271+
if (unmarshalledEntry.productId) {
272+
request.log.debug(
273+
`Deactivating Stripe product ${unmarshalledEntry.productId}`,
274+
);
275+
await deactivateStripeProduct({
276+
stripeApiKey: secretApiConfig.stripe_secret_key as string,
277+
productId: unmarshalledEntry.productId,
278+
});
279+
}
280+
request.log.debug(`Deactivating Stripe link ${linkId}`);
281+
await deactivateStripeLink({
282+
stripeApiKey: secretApiConfig.stripe_secret_key as string,
283+
linkId,
284+
});
285+
await fastify.dynamoClient.send(dynamoCommand);
286+
return reply.status(201).send();
287+
},
288+
);
180289
fastify.post(
181290
"/webhook",
182291
{

tests/unit/stripe.test.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { afterAll, expect, test, beforeEach, vi, describe } from "vitest";
1+
import {
2+
afterAll,
3+
expect,
4+
test,
5+
beforeEach,
6+
vi,
7+
describe,
8+
afterEach,
9+
} from "vitest";
210
import init from "../../src/api/index.js";
311
import { mockClient } from "aws-sdk-client-mock";
412
import { secretJson } from "./secret.testdata.js";
@@ -13,7 +21,6 @@ import supertest from "supertest";
1321
import { createJwt } from "./auth.test.js";
1422
import { v4 as uuidv4 } from "uuid";
1523
import { marshall } from "@aws-sdk/util-dynamodb";
16-
import { genericConfig } from "../../src/common/config.js";
1724

1825
const ddbMock = mockClient(DynamoDBClient);
1926
const linkId = uuidv4();
@@ -31,12 +38,14 @@ vi.mock("stripe", () => {
3138
default: vi.fn(() => ({
3239
products: {
3340
create: vi.fn().mockResolvedValue(productMock),
41+
update: vi.fn().mockResolvedValue({}),
3442
},
3543
prices: {
3644
create: vi.fn().mockResolvedValue(priceMock),
3745
},
3846
paymentLinks: {
3947
create: vi.fn().mockResolvedValue(paymentLinkMock),
48+
update: vi.fn().mockResolvedValue({}),
4049
},
4150
})),
4251
};
@@ -207,7 +216,7 @@ describe("Test Stripe link creation", async () => {
207216
ddbMock
208217
.on(ScanCommand)
209218
.rejects(new Error("Should not be called when OLA is enforced!"));
210-
ddbMock.on(QueryCommand).resolves({
219+
ddbMock.on(QueryCommand).resolvesOnce({
211220
Count: 1,
212221
Items: [
213222
marshall({
@@ -242,9 +251,62 @@ describe("Test Stripe link creation", async () => {
242251
},
243252
]);
244253
});
254+
test("DELETE happy path", async () => {
255+
ddbMock.on(QueryCommand).resolvesOnce({
256+
Items: [
257+
marshall({
258+
userId: "[email protected]",
259+
invoiceId: "UNITTEST1",
260+
amount: 10000,
261+
priceId: "price_abc123",
262+
productId: "prod_abc123",
263+
}),
264+
],
265+
});
266+
ddbMock.on(TransactWriteItemsCommand).resolvesOnce({});
267+
const testJwt = createJwt();
268+
await app.ready();
269+
270+
const response = await supertest(app.server)
271+
.delete("/api/v1/stripe/paymentLinks/plink_abc123")
272+
.set("authorization", `Bearer ${testJwt}`)
273+
.send();
274+
expect(response.statusCode).toBe(201);
275+
expect(ddbMock.calls().length).toEqual(2);
276+
});
277+
test("DELETE fails on not user-owned links", async () => {
278+
await app.ready();
279+
ddbMock.on(QueryCommand).resolvesOnce({
280+
Items: [
281+
marshall({
282+
userId: "[email protected]",
283+
invoiceId: "UNITTEST1",
284+
amount: 10000,
285+
priceId: "price_abc123",
286+
productId: "prod_abc123",
287+
}),
288+
],
289+
});
290+
ddbMock.on(TransactWriteItemsCommand).rejects();
291+
const testJwt = createJwt(
292+
undefined,
293+
["999"],
294+
295+
);
296+
297+
const response = await supertest(app.server)
298+
.delete("/api/v1/stripe/paymentLinks/plink_abc123")
299+
.set("authorization", `Bearer ${testJwt}`)
300+
.send();
301+
expect(response.statusCode).toBe(401);
302+
expect(ddbMock.calls().length).toEqual(1);
303+
});
245304
afterAll(async () => {
246305
await app.close();
247306
});
307+
afterEach(() => {
308+
ddbMock.reset();
309+
});
248310
beforeEach(() => {
249311
(app as any).nodeCache.flushAll();
250312
vi.clearAllMocks();

tests/unit/vitest.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ export default defineConfig({
1313
include: ["src/api/**/*.ts", "src/common/**/*.ts"],
1414
exclude: ["src/api/lambda.ts", "src/api/sqs/handlers/templates/*.ts"],
1515
thresholds: {
16-
statements: 54,
17-
functions: 65,
16+
statements: 55,
17+
functions: 66,
1818
lines: 54,
1919
},
2020
},

tests/unit/vitest.setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ vi.mock(
5151
"scanner-only": [AppRoles.TICKETS_SCANNER],
5252
LINKS_ADMIN: [AppRoles.LINKS_ADMIN],
5353
LINKS_MANAGER: [AppRoles.LINKS_MANAGER],
54+
"999": [AppRoles.STRIPE_LINK_CREATOR],
5455
};
5556

5657
return mockGroupRoles[groupId as any] || [];

0 commit comments

Comments
 (0)