Skip to content

Commit 6e62bbe

Browse files
Kikolatorclaude
andauthored
fix: address code review findings for billing feature (#142)
## Summary Review fixes for #141 that were pushed after the PR was merged. - Fix comma-operator bug in Stripe product ID resolution (could fail silently) - Paginate cron query with batches of 500 for multi-tenant scale - Warn admin when custom price change doesn't sync to Stripe subscription - Surface warnings in add/edit member forms instead of swallowing them - Use plan currency instead of hardcoded EUR in member detail - Use Postgres enum type for `billing_mode` instead of text+CHECK - Calculate credit validity as next anniversary date, not fixed 30 days - Add TODO comments for post-migration type regen ## Test plan - [x] 351 unit tests pass, type-check clean - [ ] Verify Stripe product ID resolution works for plans without existing Stripe product - [ ] Verify cron processes members in batches correctly - [ ] Verify warning appears when changing custom price on Stripe-billed member 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a00f4ec commit 6e62bbe

File tree

8 files changed

+139
-67
lines changed

8 files changed

+139
-67
lines changed

apps/web/app/(app)/admin/members/actions.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,27 @@ export async function updateMember(memberId: string, input: unknown) {
4646
}
4747

4848
const { supabase, spaceId } = await getSpaceId();
49+
const admin = createAdminClient();
4950
const d = parsed.data;
5051

52+
// Check if custom price changed on a Stripe-billed member
53+
// TODO: remove unknown casts after running `supabase gen types` with billing_mode migration
54+
const { data: currentRaw } = await admin
55+
.from("members")
56+
.select("custom_price_cents")
57+
.eq("id", memberId)
58+
.eq("space_id", spaceId)
59+
.single();
60+
const current = currentRaw as unknown as {
61+
billing_mode?: string;
62+
custom_price_cents: number | null;
63+
} | null;
64+
65+
const priceChanged =
66+
current &&
67+
current.billing_mode === "stripe" &&
68+
d.customPriceCents !== current.custom_price_cents;
69+
5170
const { error } = await supabase
5271
.from("members")
5372
.update({
@@ -81,6 +100,14 @@ export async function updateMember(memberId: string, input: unknown) {
81100
}
82101

83102
revalidatePath("/admin/members");
103+
104+
if (priceChanged) {
105+
return {
106+
success: true as const,
107+
warning: "Custom price updated in the database. The active Stripe subscription was not changed — update it manually in the Stripe dashboard if needed.",
108+
};
109+
}
110+
84111
return { success: true as const };
85112
}
86113

@@ -280,10 +307,17 @@ export async function addMember(input: unknown) {
280307

281308
let priceId: string;
282309
if (d.customPriceCents != null && d.customPriceCents !== plan.price_cents) {
283-
// Create a custom Stripe price for this member
284-
const productId = plan.stripe_product_id
285-
?? (await ensureStripePriceExists(plan, stripeAccountId, spaceId),
286-
(await admin.from("plans").select("stripe_product_id").eq("id", plan.id).single()).data?.stripe_product_id);
310+
// Ensure Stripe product exists, then resolve the product ID
311+
let productId = plan.stripe_product_id;
312+
if (!productId) {
313+
await ensureStripePriceExists(plan, stripeAccountId, spaceId);
314+
const { data: refreshedPlan } = await admin
315+
.from("plans")
316+
.select("stripe_product_id")
317+
.eq("id", plan.id)
318+
.single();
319+
productId = refreshedPlan?.stripe_product_id ?? null;
320+
}
287321

288322
if (!productId) throw new Error("Could not resolve Stripe product");
289323

@@ -341,8 +375,9 @@ export async function addMember(input: unknown) {
341375
} else {
342376
// Manual billing — grant initial credits immediately
343377
try {
344-
const validUntil = new Date();
345-
validUntil.setDate(validUntil.getDate() + 30);
378+
// Credits valid until same day next month
379+
const now = new Date();
380+
const validUntil = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate());
346381

347382
await grantMonthlyCredits({
348383
spaceId,

apps/web/app/(app)/admin/members/add-member-form.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ export function AddMemberForm({ open, onOpenChange, plans }: AddMemberFormProps)
6969
const result = await addMember(data);
7070
if (!result.success) {
7171
setServerError(result.error);
72+
} else if ("warning" in result && result.warning) {
73+
// Show warning but still close — member was created
74+
setServerError(result.warning);
75+
setTimeout(() => {
76+
reset();
77+
onOpenChange(false);
78+
}, 4000);
7279
} else {
7380
reset();
7481
onOpenChange(false);

apps/web/app/(app)/admin/members/member-detail.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ interface MemberDetailProps {
6666
member: Member;
6767
profile: ProfileEntry;
6868
planName: string;
69+
planCurrency: string;
6970
deskName: string | null;
7071
notes: MemberNote[];
7172
profileMap: Record<string, ProfileEntry>;
@@ -117,6 +118,7 @@ export function MemberDetail({
117118
member,
118119
profile,
119120
planName,
121+
planCurrency,
120122
deskName,
121123
notes,
122124
profileMap,
@@ -196,7 +198,7 @@ export function MemberDetail({
196198
{member.custom_price_cents != null && (
197199
<DetailRow
198200
label="Custom price"
199-
value={`${(member.custom_price_cents / 100).toFixed(2)} EUR`}
201+
value={`${(member.custom_price_cents / 100).toFixed(2)} ${planCurrency.toUpperCase()}`}
200202
/>
201203
)}
202204
<DetailRow

apps/web/app/(app)/admin/members/member-form.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ export function MemberForm({ open, onOpenChange, member, plans, desks, deskAssig
148148
const result = await updateMember(member.id, data);
149149
if (!result.success) {
150150
setServerError(result.error);
151+
} else if ("warning" in result && result.warning) {
152+
setServerError(result.warning);
153+
setTimeout(() => onOpenChange(false), 4000);
151154
} else {
152155
onOpenChange(false);
153156
}

apps/web/app/(app)/admin/members/members-table.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,7 @@ export function MembersTable({
637637
member={detailMember}
638638
profile={detailProfile}
639639
planName={detailMember.plan?.name ?? "Unknown"}
640+
planCurrency={plans.find((p) => p.id === detailMember.plan_id)?.currency ?? "eur"}
640641
deskName={detailDeskName}
641642
notes={detailNotes}
642643
profileMap={profileMap}

apps/web/app/(app)/admin/members/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export default async function MembersPage() {
5757

5858
return (
5959
<MembersTable
60+
// TODO: remove casts after running `supabase gen types` with the billing_mode migration
6061
members={(members ?? []).map((m) => ({
6162
...m,
6263
billing_mode: (m as Record<string, unknown>).billing_mode as string | null ?? null,

apps/web/app/api/cron/renew-credits/route.ts

Lines changed: 73 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@ import { createLogger } from "@cowork/shared";
33
import { createAdminClient } from "@/lib/supabase/admin";
44
import { grantMonthlyCredits, expireRenewableCredits } from "@/lib/credits/grant";
55

6+
const BATCH_SIZE = 500;
7+
8+
/** Calculate the next anniversary date from a join date. */
9+
function getNextAnniversary(joinedAt: Date, today: Date): Date {
10+
const nextMonth = today.getMonth() + 1;
11+
const nextYear = today.getFullYear() + (nextMonth > 11 ? 1 : 0);
12+
const joinDay = joinedAt.getDate();
13+
// Clamp to last day of next month if join day exceeds it
14+
const lastDayOfNextMonth = new Date(nextYear, (nextMonth % 12) + 1, 0).getDate();
15+
const day = Math.min(joinDay, lastDayOfNextMonth);
16+
return new Date(nextYear, nextMonth % 12, day);
17+
}
18+
619
/**
720
* Daily cron: renew credits for manual-billing members on their monthly anniversary.
821
* Runs every day at midnight UTC. Configured in vercel.json.
@@ -19,7 +32,6 @@ export async function GET(request: Request) {
1932
const today = new Date();
2033
const dayOfMonth = today.getDate();
2134

22-
// Find manual-billing active members whose joined_at day matches today.
2335
// For months shorter than the join day (e.g., joined on 31st, current month
2436
// has 28 days), we renew on the last day of the month.
2537
const lastDayOfMonth = new Date(
@@ -29,65 +41,70 @@ export async function GET(request: Request) {
2941
).getDate();
3042
const isLastDay = dayOfMonth === lastDayOfMonth;
3143

32-
// Query members: day of month matches, OR it's the last day and their
33-
// join day exceeds this month's length
34-
const { data: members, error: queryError } = await admin
35-
.from("members")
36-
.select("id, user_id, plan_id, space_id, joined_at")
37-
.eq("billing_mode", "manual")
38-
.eq("status", "active");
39-
40-
if (queryError) {
41-
logger.error("Failed to query manual members", { error: queryError.message });
42-
return NextResponse.json({ error: "Query failed" }, { status: 500 });
43-
}
44-
45-
if (!members || members.length === 0) {
46-
return NextResponse.json({ processed: 0 });
47-
}
48-
4944
let processed = 0;
5045
let errors = 0;
46+
let offset = 0;
47+
48+
// Paginated query to handle large member counts across all tenants
49+
while (true) {
50+
const { data: members, error: queryError } = await admin
51+
.from("members")
52+
.select("id, user_id, plan_id, space_id, joined_at")
53+
.eq("billing_mode", "manual")
54+
.eq("status", "active")
55+
.order("id")
56+
.range(offset, offset + BATCH_SIZE - 1);
57+
58+
if (queryError) {
59+
logger.error("Failed to query manual members", { error: queryError.message, offset });
60+
return NextResponse.json({ error: "Query failed" }, { status: 500 });
61+
}
5162

52-
for (const member of members) {
53-
if (!member.joined_at) continue;
54-
55-
const joinDay = new Date(member.joined_at).getDate();
56-
57-
// Check if today is this member's renewal day
58-
const isDueToday =
59-
joinDay === dayOfMonth ||
60-
(isLastDay && joinDay > lastDayOfMonth);
61-
62-
if (!isDueToday) continue;
63-
64-
try {
65-
// Expire previous month's credits
66-
await expireRenewableCredits({
67-
spaceId: member.space_id,
68-
userId: member.user_id,
69-
});
70-
71-
// Grant new credits
72-
const validUntil = new Date();
73-
validUntil.setDate(validUntil.getDate() + 30);
74-
75-
await grantMonthlyCredits({
76-
spaceId: member.space_id,
77-
userId: member.user_id,
78-
planId: member.plan_id,
79-
stripeInvoiceId: `manual_renewal_${member.id}_${today.toISOString().slice(0, 10)}`,
80-
validUntil,
81-
});
82-
83-
processed++;
84-
} catch (err) {
85-
errors++;
86-
logger.error("Failed to renew credits for manual member", {
87-
memberId: member.id,
88-
error: err instanceof Error ? err.message : String(err),
89-
});
63+
if (!members || members.length === 0) break;
64+
65+
for (const member of members) {
66+
if (!member.joined_at) continue;
67+
68+
const joinDate = new Date(member.joined_at);
69+
const joinDay = joinDate.getDate();
70+
71+
// Check if today is this member's renewal day
72+
const isDueToday =
73+
joinDay === dayOfMonth ||
74+
(isLastDay && joinDay > lastDayOfMonth);
75+
76+
if (!isDueToday) continue;
77+
78+
try {
79+
// Expire previous month's credits
80+
await expireRenewableCredits({
81+
spaceId: member.space_id,
82+
userId: member.user_id,
83+
});
84+
85+
// Grant new credits valid until next anniversary
86+
const validUntil = getNextAnniversary(joinDate, today);
87+
88+
await grantMonthlyCredits({
89+
spaceId: member.space_id,
90+
userId: member.user_id,
91+
planId: member.plan_id,
92+
stripeInvoiceId: `manual_renewal_${member.id}_${today.toISOString().slice(0, 10)}`,
93+
validUntil,
94+
});
95+
96+
processed++;
97+
} catch (err) {
98+
errors++;
99+
logger.error("Failed to renew credits for manual member", {
100+
memberId: member.id,
101+
error: err instanceof Error ? err.message : String(err),
102+
});
103+
}
90104
}
105+
106+
if (members.length < BATCH_SIZE) break;
107+
offset += BATCH_SIZE;
91108
}
92109

93110
logger.info("Manual credit renewal completed", { processed, errors });

packages/db/supabase/migrations/20260331173515_add_billing_mode_custom_price.sql

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@
55
-- ============================================================================
66

77
-- ==========================================================================
8-
-- 1. Add columns
8+
-- 1. Create enum type
9+
-- ==========================================================================
10+
11+
CREATE TYPE billing_mode AS ENUM ('stripe', 'manual');
12+
13+
-- ==========================================================================
14+
-- 2. Add columns
915
-- ==========================================================================
1016

1117
ALTER TABLE members
12-
ADD COLUMN billing_mode text NOT NULL DEFAULT 'stripe'
13-
CHECK (billing_mode IN ('stripe', 'manual')),
18+
ADD COLUMN billing_mode billing_mode NOT NULL DEFAULT 'stripe',
1419
ADD COLUMN custom_price_cents integer
1520
CHECK (custom_price_cents IS NULL OR custom_price_cents >= 0);
1621

@@ -22,7 +27,8 @@ UPDATE members
2227
AND stripe_customer_id IS NULL;
2328

2429
-- ==========================================================================
25-
-- 2. Rollback
30+
-- 3. Rollback
2631
-- ==========================================================================
2732
-- ALTER TABLE members DROP COLUMN custom_price_cents;
2833
-- ALTER TABLE members DROP COLUMN billing_mode;
34+
-- DROP TYPE IF EXISTS billing_mode;

0 commit comments

Comments
 (0)