@wopr-network/platform-core 1.46.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/trpc/index.d.ts +1 -0
- package/dist/trpc/index.js +1 -0
- package/dist/trpc/org-remove-payment-method-router.d.ts +23 -0
- package/dist/trpc/org-remove-payment-method-router.js +61 -0
- package/dist/trpc/org-remove-payment-method-router.test.d.ts +1 -0
- package/dist/trpc/org-remove-payment-method-router.test.js +166 -0
- package/package.json +1 -1
- package/src/trpc/index.ts +4 -0
- package/src/trpc/org-remove-payment-method-router.test.ts +188 -0
- package/src/trpc/org-remove-payment-method-router.ts +80 -0
package/dist/trpc/index.d.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { createAdminFleetUpdateRouter } from "./admin-fleet-update-router.js";
|
|
|
2
2
|
export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
|
|
3
3
|
export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, type TRPCContext, tenantProcedure, } from "./init.js";
|
|
4
4
|
export { createNotificationTemplateRouter } from "./notification-template-router.js";
|
|
5
|
+
export { createOrgRemovePaymentMethodRouter, type OrgRemovePaymentMethodDeps, } from "./org-remove-payment-method-router.js";
|
package/dist/trpc/index.js
CHANGED
|
@@ -2,3 +2,4 @@ export { createAdminFleetUpdateRouter } from "./admin-fleet-update-router.js";
|
|
|
2
2
|
export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
|
|
3
3
|
export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, tenantProcedure, } from "./init.js";
|
|
4
4
|
export { createNotificationTemplateRouter } from "./notification-template-router.js";
|
|
5
|
+
export { createOrgRemovePaymentMethodRouter, } from "./org-remove-payment-method-router.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IPaymentProcessor } from "../billing/payment-processor.js";
|
|
2
|
+
import type { IAutoTopupSettingsRepository } from "../credits/auto-topup-settings-repository.js";
|
|
3
|
+
export interface OrgRemovePaymentMethodDeps {
|
|
4
|
+
processor: IPaymentProcessor;
|
|
5
|
+
autoTopupSettingsStore?: IAutoTopupSettingsRepository;
|
|
6
|
+
}
|
|
7
|
+
export declare function createOrgRemovePaymentMethodRouter(getDeps: () => OrgRemovePaymentMethodDeps): import("@trpc/server").TRPCBuiltRouter<{
|
|
8
|
+
ctx: import("./init.js").TRPCContext;
|
|
9
|
+
meta: object;
|
|
10
|
+
errorShape: import("@trpc/server").TRPCDefaultErrorShape;
|
|
11
|
+
transformer: false;
|
|
12
|
+
}, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
|
|
13
|
+
orgRemovePaymentMethod: import("@trpc/server").TRPCMutationProcedure<{
|
|
14
|
+
input: {
|
|
15
|
+
orgId: string;
|
|
16
|
+
paymentMethodId: string;
|
|
17
|
+
};
|
|
18
|
+
output: {
|
|
19
|
+
removed: boolean;
|
|
20
|
+
};
|
|
21
|
+
meta: object;
|
|
22
|
+
}>;
|
|
23
|
+
}>>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { PaymentMethodOwnershipError } from "../billing/payment-processor.js";
|
|
4
|
+
import { orgAdminProcedure, router } from "./init.js";
|
|
5
|
+
export function createOrgRemovePaymentMethodRouter(getDeps) {
|
|
6
|
+
return router({
|
|
7
|
+
orgRemovePaymentMethod: orgAdminProcedure
|
|
8
|
+
.input(z.object({
|
|
9
|
+
orgId: z.string().min(1),
|
|
10
|
+
paymentMethodId: z.string().min(1),
|
|
11
|
+
}))
|
|
12
|
+
.mutation(async ({ input }) => {
|
|
13
|
+
const { processor, autoTopupSettingsStore } = getDeps();
|
|
14
|
+
if (!autoTopupSettingsStore) {
|
|
15
|
+
console.warn("orgRemovePaymentMethod: autoTopupSettingsStore not provided — last-payment-method guard is inactive");
|
|
16
|
+
}
|
|
17
|
+
// Guard: prevent removing the last payment method when auto-topup is enabled
|
|
18
|
+
if (autoTopupSettingsStore) {
|
|
19
|
+
const methods = await processor.listPaymentMethods(input.orgId);
|
|
20
|
+
if (methods.length <= 1) {
|
|
21
|
+
const settings = await autoTopupSettingsStore.getByTenant(input.orgId);
|
|
22
|
+
if (settings && (settings.usageEnabled || settings.scheduleEnabled)) {
|
|
23
|
+
throw new TRPCError({
|
|
24
|
+
code: "FORBIDDEN",
|
|
25
|
+
message: "Cannot remove last payment method while auto-topup is enabled. Disable auto-topup first.",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
await processor.detachPaymentMethod(input.orgId, input.paymentMethodId);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (err instanceof PaymentMethodOwnershipError) {
|
|
35
|
+
throw new TRPCError({
|
|
36
|
+
code: "FORBIDDEN",
|
|
37
|
+
message: "Payment method does not belong to this organization",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
throw new TRPCError({
|
|
41
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
42
|
+
message: "Failed to remove payment method. Please try again.",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// TOCTOU guard: re-check count after detach. A concurrent request
|
|
46
|
+
// may have already removed another payment method between our pre-check
|
|
47
|
+
// and this detach, leaving the org with 0 methods while auto-topup is
|
|
48
|
+
// still enabled. Warn operators — the method is already gone.
|
|
49
|
+
if (autoTopupSettingsStore) {
|
|
50
|
+
const remaining = await processor.listPaymentMethods(input.orgId);
|
|
51
|
+
if (remaining.length === 0) {
|
|
52
|
+
const settings = await autoTopupSettingsStore.getByTenant(input.orgId);
|
|
53
|
+
if (settings && (settings.usageEnabled || settings.scheduleEnabled)) {
|
|
54
|
+
console.warn("orgRemovePaymentMethod: TOCTOU — org %s now has 0 payment methods with auto-topup enabled. Operator must add a payment method or disable auto-topup.", input.orgId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { removed: true };
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createCallerFactory, router, setTrpcOrgMemberRepo } from "./init.js";
|
|
3
|
+
import { createOrgRemovePaymentMethodRouter } from "./org-remove-payment-method-router.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Mock helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
function makeMockOrgMemberRepo(overrides) {
|
|
8
|
+
return {
|
|
9
|
+
listMembers: vi.fn().mockResolvedValue([]),
|
|
10
|
+
addMember: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
updateMemberRole: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
removeMember: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
findMember: vi.fn().mockResolvedValue({ userId: "u1", role: "owner" }),
|
|
14
|
+
countAdminsAndOwners: vi.fn().mockResolvedValue(1),
|
|
15
|
+
listInvites: vi.fn().mockResolvedValue([]),
|
|
16
|
+
createInvite: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
findInviteById: vi.fn().mockResolvedValue(null),
|
|
18
|
+
findInviteByToken: vi.fn().mockResolvedValue(null),
|
|
19
|
+
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
23
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function makeMockProcessor(methods) {
|
|
28
|
+
return {
|
|
29
|
+
name: "mock",
|
|
30
|
+
createCheckoutSession: vi.fn(),
|
|
31
|
+
handleWebhook: vi.fn(),
|
|
32
|
+
supportsPortal: vi.fn().mockReturnValue(false),
|
|
33
|
+
createPortalSession: vi.fn(),
|
|
34
|
+
setupPaymentMethod: vi.fn(),
|
|
35
|
+
listPaymentMethods: vi.fn().mockResolvedValue(methods),
|
|
36
|
+
charge: vi.fn(),
|
|
37
|
+
detachPaymentMethod: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
getCustomerEmail: vi.fn().mockResolvedValue(""),
|
|
39
|
+
updateCustomerEmail: vi.fn(),
|
|
40
|
+
listInvoices: vi.fn().mockResolvedValue([]),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function makeMockAutoTopupSettings(overrides) {
|
|
44
|
+
const settings = overrides
|
|
45
|
+
? {
|
|
46
|
+
tenantId: "org-1",
|
|
47
|
+
usageEnabled: false,
|
|
48
|
+
usageThreshold: { toCentsRounded: () => 0 },
|
|
49
|
+
usageTopup: { toCentsRounded: () => 0 },
|
|
50
|
+
usageConsecutiveFailures: 0,
|
|
51
|
+
usageChargeInFlight: false,
|
|
52
|
+
scheduleEnabled: false,
|
|
53
|
+
scheduleAmount: { toCentsRounded: () => 0 },
|
|
54
|
+
scheduleIntervalHours: 0,
|
|
55
|
+
scheduleNextAt: null,
|
|
56
|
+
scheduleConsecutiveFailures: 0,
|
|
57
|
+
createdAt: new Date().toISOString(),
|
|
58
|
+
updatedAt: new Date().toISOString(),
|
|
59
|
+
...overrides,
|
|
60
|
+
}
|
|
61
|
+
: null;
|
|
62
|
+
return {
|
|
63
|
+
getByTenant: vi.fn().mockResolvedValue(settings),
|
|
64
|
+
upsert: vi.fn(),
|
|
65
|
+
setUsageChargeInFlight: vi.fn(),
|
|
66
|
+
tryAcquireUsageInFlight: vi.fn(),
|
|
67
|
+
incrementUsageFailures: vi.fn(),
|
|
68
|
+
resetUsageFailures: vi.fn(),
|
|
69
|
+
disableUsage: vi.fn(),
|
|
70
|
+
incrementScheduleFailures: vi.fn(),
|
|
71
|
+
resetScheduleFailures: vi.fn(),
|
|
72
|
+
disableSchedule: vi.fn(),
|
|
73
|
+
advanceScheduleNextAt: vi.fn(),
|
|
74
|
+
listDueScheduled: vi.fn().mockResolvedValue([]),
|
|
75
|
+
getMaxConsecutiveFailures: vi.fn().mockResolvedValue(0),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function authedContext() {
|
|
79
|
+
return { user: { id: "u1", roles: ["user"] }, tenantId: "org-1" };
|
|
80
|
+
}
|
|
81
|
+
function unauthedContext() {
|
|
82
|
+
return { user: undefined, tenantId: undefined };
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Tests
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
describe("orgRemovePaymentMethod router", () => {
|
|
88
|
+
let processor;
|
|
89
|
+
let autoTopupStore;
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
processor = makeMockProcessor([{ id: "pm_1", label: "Visa ending 4242", isDefault: true }]);
|
|
92
|
+
autoTopupStore = makeMockAutoTopupSettings();
|
|
93
|
+
setTrpcOrgMemberRepo(makeMockOrgMemberRepo());
|
|
94
|
+
});
|
|
95
|
+
function buildCaller(deps) {
|
|
96
|
+
const subRouter = createOrgRemovePaymentMethodRouter(() => ({
|
|
97
|
+
processor: deps.processor,
|
|
98
|
+
autoTopupSettingsStore: deps.autoTopupSettingsStore,
|
|
99
|
+
}));
|
|
100
|
+
const appRouter = router({ org: subRouter });
|
|
101
|
+
return createCallerFactory(appRouter);
|
|
102
|
+
}
|
|
103
|
+
it("successfully removes a payment method", async () => {
|
|
104
|
+
const caller = buildCaller({ processor, autoTopupSettingsStore: autoTopupStore })(authedContext());
|
|
105
|
+
const result = await caller.org.orgRemovePaymentMethod({
|
|
106
|
+
orgId: "org-1",
|
|
107
|
+
paymentMethodId: "pm_1",
|
|
108
|
+
});
|
|
109
|
+
expect(result).toEqual({ removed: true });
|
|
110
|
+
expect(processor.detachPaymentMethod).toHaveBeenCalledWith("org-1", "pm_1");
|
|
111
|
+
});
|
|
112
|
+
it("rejects unauthenticated users", async () => {
|
|
113
|
+
const caller = buildCaller({ processor, autoTopupSettingsStore: autoTopupStore })(unauthedContext());
|
|
114
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
115
|
+
code: "UNAUTHORIZED",
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
it("rejects when removing last PM with auto-topup usage enabled", async () => {
|
|
119
|
+
const usageStore = makeMockAutoTopupSettings({ usageEnabled: true });
|
|
120
|
+
const caller = buildCaller({
|
|
121
|
+
processor,
|
|
122
|
+
autoTopupSettingsStore: usageStore,
|
|
123
|
+
})(authedContext());
|
|
124
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
125
|
+
code: "FORBIDDEN",
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
it("rejects when removing last PM with auto-topup schedule enabled", async () => {
|
|
129
|
+
const scheduleStore = makeMockAutoTopupSettings({ scheduleEnabled: true });
|
|
130
|
+
const caller = buildCaller({
|
|
131
|
+
processor,
|
|
132
|
+
autoTopupSettingsStore: scheduleStore,
|
|
133
|
+
})(authedContext());
|
|
134
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
135
|
+
code: "FORBIDDEN",
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
it("allows removing non-last PM even with auto-topup enabled", async () => {
|
|
139
|
+
const multiProcessor = makeMockProcessor([
|
|
140
|
+
{ id: "pm_1", label: "Visa ending 4242", isDefault: true },
|
|
141
|
+
{ id: "pm_2", label: "Mastercard ending 5555", isDefault: false },
|
|
142
|
+
]);
|
|
143
|
+
const usageStore = makeMockAutoTopupSettings({ usageEnabled: true });
|
|
144
|
+
const caller = buildCaller({
|
|
145
|
+
processor: multiProcessor,
|
|
146
|
+
autoTopupSettingsStore: usageStore,
|
|
147
|
+
})(authedContext());
|
|
148
|
+
const result = await caller.org.orgRemovePaymentMethod({
|
|
149
|
+
orgId: "org-1",
|
|
150
|
+
paymentMethodId: "pm_1",
|
|
151
|
+
});
|
|
152
|
+
expect(result).toEqual({ removed: true });
|
|
153
|
+
});
|
|
154
|
+
it("returns FORBIDDEN when detachPaymentMethod throws PaymentMethodOwnershipError", async () => {
|
|
155
|
+
const { PaymentMethodOwnershipError } = await import("../billing/payment-processor.js");
|
|
156
|
+
const ownershipErrorProcessor = makeMockProcessor([]);
|
|
157
|
+
ownershipErrorProcessor.detachPaymentMethod.mockRejectedValue(new PaymentMethodOwnershipError());
|
|
158
|
+
const caller = buildCaller({
|
|
159
|
+
processor: ownershipErrorProcessor,
|
|
160
|
+
autoTopupSettingsStore: autoTopupStore,
|
|
161
|
+
})(authedContext());
|
|
162
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
163
|
+
code: "FORBIDDEN",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|
package/package.json
CHANGED
package/src/trpc/index.ts
CHANGED
|
@@ -13,3 +13,7 @@ export {
|
|
|
13
13
|
tenantProcedure,
|
|
14
14
|
} from "./init.js";
|
|
15
15
|
export { createNotificationTemplateRouter } from "./notification-template-router.js";
|
|
16
|
+
export {
|
|
17
|
+
createOrgRemovePaymentMethodRouter,
|
|
18
|
+
type OrgRemovePaymentMethodDeps,
|
|
19
|
+
} from "./org-remove-payment-method-router.js";
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { IPaymentProcessor, SavedPaymentMethod } from "../billing/payment-processor.js";
|
|
3
|
+
import type { AutoTopupSettings, IAutoTopupSettingsRepository } from "../credits/auto-topup-settings-repository.js";
|
|
4
|
+
import type { IOrgMemberRepository } from "../tenancy/org-member-repository.js";
|
|
5
|
+
import { createCallerFactory, router, setTrpcOrgMemberRepo } from "./init.js";
|
|
6
|
+
import { createOrgRemovePaymentMethodRouter } from "./org-remove-payment-method-router.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Mock helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
function makeMockOrgMemberRepo(overrides?: Partial<IOrgMemberRepository>): IOrgMemberRepository {
|
|
13
|
+
return {
|
|
14
|
+
listMembers: vi.fn().mockResolvedValue([]),
|
|
15
|
+
addMember: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
updateMemberRole: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
removeMember: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
findMember: vi.fn().mockResolvedValue({ userId: "u1", role: "owner" }),
|
|
19
|
+
countAdminsAndOwners: vi.fn().mockResolvedValue(1),
|
|
20
|
+
listInvites: vi.fn().mockResolvedValue([]),
|
|
21
|
+
createInvite: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
findInviteById: vi.fn().mockResolvedValue(null),
|
|
23
|
+
findInviteByToken: vi.fn().mockResolvedValue(null),
|
|
24
|
+
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
28
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeMockProcessor(methods: SavedPaymentMethod[]): IPaymentProcessor {
|
|
34
|
+
return {
|
|
35
|
+
name: "mock",
|
|
36
|
+
createCheckoutSession: vi.fn(),
|
|
37
|
+
handleWebhook: vi.fn(),
|
|
38
|
+
supportsPortal: vi.fn().mockReturnValue(false),
|
|
39
|
+
createPortalSession: vi.fn(),
|
|
40
|
+
setupPaymentMethod: vi.fn(),
|
|
41
|
+
listPaymentMethods: vi.fn().mockResolvedValue(methods),
|
|
42
|
+
charge: vi.fn(),
|
|
43
|
+
detachPaymentMethod: vi.fn().mockResolvedValue(undefined),
|
|
44
|
+
getCustomerEmail: vi.fn().mockResolvedValue(""),
|
|
45
|
+
updateCustomerEmail: vi.fn(),
|
|
46
|
+
listInvoices: vi.fn().mockResolvedValue([]),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeMockAutoTopupSettings(overrides?: Partial<AutoTopupSettings>): IAutoTopupSettingsRepository {
|
|
51
|
+
const settings: AutoTopupSettings | null = overrides
|
|
52
|
+
? ({
|
|
53
|
+
tenantId: "org-1",
|
|
54
|
+
usageEnabled: false,
|
|
55
|
+
usageThreshold: { toCentsRounded: () => 0 },
|
|
56
|
+
usageTopup: { toCentsRounded: () => 0 },
|
|
57
|
+
usageConsecutiveFailures: 0,
|
|
58
|
+
usageChargeInFlight: false,
|
|
59
|
+
scheduleEnabled: false,
|
|
60
|
+
scheduleAmount: { toCentsRounded: () => 0 },
|
|
61
|
+
scheduleIntervalHours: 0,
|
|
62
|
+
scheduleNextAt: null,
|
|
63
|
+
scheduleConsecutiveFailures: 0,
|
|
64
|
+
createdAt: new Date().toISOString(),
|
|
65
|
+
updatedAt: new Date().toISOString(),
|
|
66
|
+
...overrides,
|
|
67
|
+
} as AutoTopupSettings)
|
|
68
|
+
: null;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
getByTenant: vi.fn().mockResolvedValue(settings),
|
|
72
|
+
upsert: vi.fn(),
|
|
73
|
+
setUsageChargeInFlight: vi.fn(),
|
|
74
|
+
tryAcquireUsageInFlight: vi.fn(),
|
|
75
|
+
incrementUsageFailures: vi.fn(),
|
|
76
|
+
resetUsageFailures: vi.fn(),
|
|
77
|
+
disableUsage: vi.fn(),
|
|
78
|
+
incrementScheduleFailures: vi.fn(),
|
|
79
|
+
resetScheduleFailures: vi.fn(),
|
|
80
|
+
disableSchedule: vi.fn(),
|
|
81
|
+
advanceScheduleNextAt: vi.fn(),
|
|
82
|
+
listDueScheduled: vi.fn().mockResolvedValue([]),
|
|
83
|
+
getMaxConsecutiveFailures: vi.fn().mockResolvedValue(0),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function authedContext() {
|
|
88
|
+
return { user: { id: "u1", roles: ["user"] }, tenantId: "org-1" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function unauthedContext() {
|
|
92
|
+
return { user: undefined, tenantId: undefined };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Tests
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe("orgRemovePaymentMethod router", () => {
|
|
100
|
+
let processor: IPaymentProcessor;
|
|
101
|
+
let autoTopupStore: IAutoTopupSettingsRepository;
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
processor = makeMockProcessor([{ id: "pm_1", label: "Visa ending 4242", isDefault: true }]);
|
|
105
|
+
autoTopupStore = makeMockAutoTopupSettings();
|
|
106
|
+
setTrpcOrgMemberRepo(makeMockOrgMemberRepo());
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
function buildCaller(deps: { processor: IPaymentProcessor; autoTopupSettingsStore?: IAutoTopupSettingsRepository }) {
|
|
110
|
+
const subRouter = createOrgRemovePaymentMethodRouter(() => ({
|
|
111
|
+
processor: deps.processor,
|
|
112
|
+
autoTopupSettingsStore: deps.autoTopupSettingsStore,
|
|
113
|
+
}));
|
|
114
|
+
const appRouter = router({ org: subRouter });
|
|
115
|
+
return createCallerFactory(appRouter);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
it("successfully removes a payment method", async () => {
|
|
119
|
+
const caller = buildCaller({ processor, autoTopupSettingsStore: autoTopupStore })(authedContext());
|
|
120
|
+
const result = await caller.org.orgRemovePaymentMethod({
|
|
121
|
+
orgId: "org-1",
|
|
122
|
+
paymentMethodId: "pm_1",
|
|
123
|
+
});
|
|
124
|
+
expect(result).toEqual({ removed: true });
|
|
125
|
+
expect(processor.detachPaymentMethod).toHaveBeenCalledWith("org-1", "pm_1");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("rejects unauthenticated users", async () => {
|
|
129
|
+
const caller = buildCaller({ processor, autoTopupSettingsStore: autoTopupStore })(unauthedContext());
|
|
130
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
131
|
+
code: "UNAUTHORIZED",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("rejects when removing last PM with auto-topup usage enabled", async () => {
|
|
136
|
+
const usageStore = makeMockAutoTopupSettings({ usageEnabled: true });
|
|
137
|
+
const caller = buildCaller({
|
|
138
|
+
processor,
|
|
139
|
+
autoTopupSettingsStore: usageStore,
|
|
140
|
+
})(authedContext());
|
|
141
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
142
|
+
code: "FORBIDDEN",
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rejects when removing last PM with auto-topup schedule enabled", async () => {
|
|
147
|
+
const scheduleStore = makeMockAutoTopupSettings({ scheduleEnabled: true });
|
|
148
|
+
const caller = buildCaller({
|
|
149
|
+
processor,
|
|
150
|
+
autoTopupSettingsStore: scheduleStore,
|
|
151
|
+
})(authedContext());
|
|
152
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
153
|
+
code: "FORBIDDEN",
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("allows removing non-last PM even with auto-topup enabled", async () => {
|
|
158
|
+
const multiProcessor = makeMockProcessor([
|
|
159
|
+
{ id: "pm_1", label: "Visa ending 4242", isDefault: true },
|
|
160
|
+
{ id: "pm_2", label: "Mastercard ending 5555", isDefault: false },
|
|
161
|
+
]);
|
|
162
|
+
const usageStore = makeMockAutoTopupSettings({ usageEnabled: true });
|
|
163
|
+
const caller = buildCaller({
|
|
164
|
+
processor: multiProcessor,
|
|
165
|
+
autoTopupSettingsStore: usageStore,
|
|
166
|
+
})(authedContext());
|
|
167
|
+
const result = await caller.org.orgRemovePaymentMethod({
|
|
168
|
+
orgId: "org-1",
|
|
169
|
+
paymentMethodId: "pm_1",
|
|
170
|
+
});
|
|
171
|
+
expect(result).toEqual({ removed: true });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns FORBIDDEN when detachPaymentMethod throws PaymentMethodOwnershipError", async () => {
|
|
175
|
+
const { PaymentMethodOwnershipError } = await import("../billing/payment-processor.js");
|
|
176
|
+
const ownershipErrorProcessor = makeMockProcessor([]);
|
|
177
|
+
(ownershipErrorProcessor.detachPaymentMethod as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
178
|
+
new PaymentMethodOwnershipError(),
|
|
179
|
+
);
|
|
180
|
+
const caller = buildCaller({
|
|
181
|
+
processor: ownershipErrorProcessor,
|
|
182
|
+
autoTopupSettingsStore: autoTopupStore,
|
|
183
|
+
})(authedContext());
|
|
184
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
185
|
+
code: "FORBIDDEN",
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { IPaymentProcessor } from "../billing/payment-processor.js";
|
|
4
|
+
import { PaymentMethodOwnershipError } from "../billing/payment-processor.js";
|
|
5
|
+
import type { IAutoTopupSettingsRepository } from "../credits/auto-topup-settings-repository.js";
|
|
6
|
+
import { orgAdminProcedure, router } from "./init.js";
|
|
7
|
+
|
|
8
|
+
export interface OrgRemovePaymentMethodDeps {
|
|
9
|
+
processor: IPaymentProcessor;
|
|
10
|
+
autoTopupSettingsStore?: IAutoTopupSettingsRepository;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createOrgRemovePaymentMethodRouter(getDeps: () => OrgRemovePaymentMethodDeps) {
|
|
14
|
+
return router({
|
|
15
|
+
orgRemovePaymentMethod: orgAdminProcedure
|
|
16
|
+
.input(
|
|
17
|
+
z.object({
|
|
18
|
+
orgId: z.string().min(1),
|
|
19
|
+
paymentMethodId: z.string().min(1),
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
.mutation(async ({ input }) => {
|
|
23
|
+
const { processor, autoTopupSettingsStore } = getDeps();
|
|
24
|
+
|
|
25
|
+
if (!autoTopupSettingsStore) {
|
|
26
|
+
console.warn(
|
|
27
|
+
"orgRemovePaymentMethod: autoTopupSettingsStore not provided — last-payment-method guard is inactive",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Guard: prevent removing the last payment method when auto-topup is enabled
|
|
32
|
+
if (autoTopupSettingsStore) {
|
|
33
|
+
const methods = await processor.listPaymentMethods(input.orgId);
|
|
34
|
+
if (methods.length <= 1) {
|
|
35
|
+
const settings = await autoTopupSettingsStore.getByTenant(input.orgId);
|
|
36
|
+
if (settings && (settings.usageEnabled || settings.scheduleEnabled)) {
|
|
37
|
+
throw new TRPCError({
|
|
38
|
+
code: "FORBIDDEN",
|
|
39
|
+
message: "Cannot remove last payment method while auto-topup is enabled. Disable auto-topup first.",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await processor.detachPaymentMethod(input.orgId, input.paymentMethodId);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err instanceof PaymentMethodOwnershipError) {
|
|
49
|
+
throw new TRPCError({
|
|
50
|
+
code: "FORBIDDEN",
|
|
51
|
+
message: "Payment method does not belong to this organization",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
throw new TRPCError({
|
|
55
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
56
|
+
message: "Failed to remove payment method. Please try again.",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// TOCTOU guard: re-check count after detach. A concurrent request
|
|
61
|
+
// may have already removed another payment method between our pre-check
|
|
62
|
+
// and this detach, leaving the org with 0 methods while auto-topup is
|
|
63
|
+
// still enabled. Warn operators — the method is already gone.
|
|
64
|
+
if (autoTopupSettingsStore) {
|
|
65
|
+
const remaining = await processor.listPaymentMethods(input.orgId);
|
|
66
|
+
if (remaining.length === 0) {
|
|
67
|
+
const settings = await autoTopupSettingsStore.getByTenant(input.orgId);
|
|
68
|
+
if (settings && (settings.usageEnabled || settings.scheduleEnabled)) {
|
|
69
|
+
console.warn(
|
|
70
|
+
"orgRemovePaymentMethod: TOCTOU — org %s now has 0 payment methods with auto-topup enabled. Operator must add a payment method or disable auto-topup.",
|
|
71
|
+
input.orgId,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { removed: true };
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
}
|