Skip to content

Commit 310f16e

Browse files
committed
codex: address PR review feedback (#199)
1 parent bce0ffa commit 310f16e

7 files changed

Lines changed: 163 additions & 14 deletions

File tree

packages/paykit/src/core/__tests__/validate-options.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ function createOptions(currency?: string): PayKitOptions {
77
return {
88
database: "postgresql://localhost:5432/paykit",
99
stripe: {
10-
...(currency ? { currency: currency as never } : {}),
10+
...(currency !== undefined ? { currency: currency as never } : {}),
1111
secretKey: "sk_test_123",
1212
webhookSecret: "whsec_123",
1313
},
@@ -31,4 +31,10 @@ describe("core/validate-options", () => {
3131
"must be a lowercase three-letter currency code",
3232
);
3333
});
34+
35+
it("rejects empty Stripe currency", () => {
36+
expect(() => assertValidPayKitOptions(createOptions(""))).toThrow(
37+
"must be a lowercase three-letter currency code",
38+
);
39+
});
3440
});

packages/paykit/src/core/validate-options.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,21 @@ export function assertValidPayKitOptions(
3030
assertValidTrustedOrigin(origin);
3131
}
3232

33-
if (options.stripe?.currency) {
34-
assertValidStripeCurrency(options.stripe.currency);
33+
const currency = options.stripe?.currency;
34+
if (currency !== undefined) {
35+
assertValidStripeCurrency(currency);
3536
}
3637
}
3738

38-
function assertValidStripeCurrency(currency: string): void {
39-
if (currency !== currency.toLowerCase() || !/^[a-z]{3}$/.test(currency)) {
39+
function assertValidStripeCurrency(currency: unknown): void {
40+
if (
41+
typeof currency !== "string" ||
42+
currency !== currency.toLowerCase() ||
43+
!/^[a-z]{3}$/.test(currency)
44+
) {
45+
const received = typeof currency === "string" ? currency : String(currency);
4046
throw new Error(
41-
`PayKit option \`stripe.currency\` must be a lowercase three-letter currency code. Received "${currency}".`,
47+
`PayKit option \`stripe.currency\` must be a lowercase three-letter currency code. Received "${received}".`,
4248
);
4349
}
4450

packages/paykit/src/customer/customer.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
product,
1212
subscription,
1313
} from "../database/schema";
14-
import { getProductByHash } from "../product/product.service";
14+
import { getProductByPlan } from "../product/product.service";
1515
import type { ProviderCustomer } from "../providers/provider";
1616
import {
1717
getActiveSubscriptionInGroup,
@@ -160,7 +160,7 @@ export async function ensureDefaultPlansForCustomer(
160160
continue;
161161
}
162162

163-
const storedPlan = await getProductByHash(ctx.database, defaultPlan.id, defaultPlan.hash);
163+
const storedPlan = await getProductByPlan(ctx.database, defaultPlan);
164164
if (!storedPlan) {
165165
continue;
166166
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import type { PayKitDatabase } from "../../database";
4+
import type { StoredProduct } from "../../types/models";
5+
import type { NormalizedPlan } from "../../types/schema";
6+
import { getProductByPlan } from "../product.service";
7+
8+
const now = new Date("2026-01-01T00:00:00Z");
9+
10+
function createStoredProduct(overrides: Partial<StoredProduct> = {}): StoredProduct {
11+
return {
12+
createdAt: now,
13+
group: "base",
14+
hash: "old_hash",
15+
id: "pro",
16+
internalId: "prod_123",
17+
isDefault: false,
18+
name: "Pro",
19+
priceAmount: 2900,
20+
priceCurrency: "usd",
21+
priceInterval: "month",
22+
stripePriceId: "price_123",
23+
stripeProductId: "prod_stripe_123",
24+
updatedAt: now,
25+
version: 1,
26+
...overrides,
27+
};
28+
}
29+
30+
function createPlan(overrides: Partial<NormalizedPlan> = {}): NormalizedPlan {
31+
return {
32+
group: "base",
33+
hash: "new_hash",
34+
id: "pro",
35+
includes: [],
36+
isDefault: false,
37+
name: "Pro",
38+
priceAmount: 2900,
39+
priceCurrency: "usd",
40+
priceInterval: "month",
41+
trialDays: null,
42+
...overrides,
43+
};
44+
}
45+
46+
function createDatabase(storedProduct: StoredProduct) {
47+
const productFindFirst = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(storedProduct);
48+
const productFeatureFindMany = vi.fn().mockResolvedValue([]);
49+
50+
return {
51+
database: {
52+
query: {
53+
product: {
54+
findFirst: productFindFirst,
55+
},
56+
productFeature: {
57+
findMany: productFeatureFindMany,
58+
},
59+
},
60+
} as unknown as PayKitDatabase,
61+
productFeatureFindMany,
62+
productFindFirst,
63+
};
64+
}
65+
66+
describe("product.service", () => {
67+
it("falls back to a semantically matching product when only the hash changed", async () => {
68+
const storedProduct = createStoredProduct();
69+
const { database } = createDatabase(storedProduct);
70+
71+
await expect(getProductByPlan(database, createPlan())).resolves.toEqual(storedProduct);
72+
});
73+
74+
it("does not fall back when the stored product differs from the normalized plan", async () => {
75+
const { database } = createDatabase(createStoredProduct({ priceAmount: 3900 }));
76+
77+
await expect(getProductByPlan(database, createPlan())).resolves.toBeNull();
78+
});
79+
});

packages/paykit/src/product/product.service.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { generateId } from "../core/utils";
55
import type { PayKitDatabase } from "../database";
66
import { feature, product, productFeature } from "../database/schema";
77
import type { StoredFeature, StoredProduct, StoredProductFeature } from "../types/models";
8-
import type { NormalizedFeature, NormalizedPlanFeature } from "../types/schema";
8+
import type { NormalizedFeature, NormalizedPlan, NormalizedPlanFeature } from "../types/schema";
99

1010
export interface StoredProductSnapshot {
1111
features: readonly StoredProductFeature[];
@@ -109,6 +109,62 @@ export async function getProductByHash(
109109
return result ?? null;
110110
}
111111

112+
function serializeFeatureConfig(config: Record<string, unknown> | null): string {
113+
return JSON.stringify(config ?? null);
114+
}
115+
116+
function productFeaturesMatch(
117+
existing: readonly StoredProductFeature[],
118+
next: readonly NormalizedPlanFeature[],
119+
): boolean {
120+
if (existing.length !== next.length) {
121+
return false;
122+
}
123+
124+
return existing.every((storedFeature, index) => {
125+
const nextFeature = next[index];
126+
return (
127+
nextFeature != null &&
128+
storedFeature.featureId === nextFeature.id &&
129+
storedFeature.limit === nextFeature.limit &&
130+
storedFeature.resetInterval === nextFeature.resetInterval &&
131+
serializeFeatureConfig(storedFeature.config) === serializeFeatureConfig(nextFeature.config)
132+
);
133+
});
134+
}
135+
136+
function productSnapshotMatchesPlan(
137+
snapshot: StoredProductSnapshot,
138+
plan: NormalizedPlan,
139+
): boolean {
140+
return (
141+
snapshot.product.group === plan.group &&
142+
snapshot.product.isDefault === plan.isDefault &&
143+
(snapshot.product.priceAmount ?? null) === plan.priceAmount &&
144+
(snapshot.product.priceCurrency ?? null) === plan.priceCurrency &&
145+
(snapshot.product.priceInterval ?? null) === plan.priceInterval &&
146+
productFeaturesMatch(snapshot.features, plan.includes)
147+
);
148+
}
149+
150+
/** Finds the stored product for a normalized plan, including old hash-compatible rows. */
151+
export async function getProductByPlan(
152+
database: PayKitDatabase,
153+
plan: NormalizedPlan,
154+
): Promise<StoredProduct | null> {
155+
const exact = await getProductByHash(database, plan.id, plan.hash);
156+
if (exact) {
157+
return exact;
158+
}
159+
160+
const latest = await getLatestProductSnapshot(database, plan.id);
161+
if (!latest || !productSnapshotMatchesPlan(latest, plan)) {
162+
return null;
163+
}
164+
165+
return latest.product;
166+
}
167+
112168
export async function getProductByInternalId(
113169
database: PayKitDatabase,
114170
internalId: string,

packages/paykit/src/subscription/subscription.service.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { upsertInvoiceRecord } from "../invoice/invoice.service";
1313
import { getDefaultPaymentMethod } from "../payment-method/payment-method.service";
1414
import {
1515
getDefaultProductInGroup,
16-
getProductByHash,
16+
getProductByPlan,
1717
getProductByInternalId,
1818
getProductByProviderData,
1919
getProductFeatures,
@@ -103,7 +103,7 @@ export async function loadSubscribeContext(ctx: PayKitContext, input: SubscribeI
103103
const matchingProduct = input.productInternalId
104104
? await getProductByInternalId(ctx.database, input.productInternalId)
105105
: normalizedPlan
106-
? await getProductByHash(ctx.database, input.planId, normalizedPlan.hash)
106+
? await getProductByPlan(ctx.database, normalizedPlan)
107107
: null;
108108
const storedPlan = matchingProduct ? withProviderInfo(matchingProduct, providerId) : null;
109109

@@ -245,11 +245,11 @@ async function cancelExistingProviderSubscriptionForCheckout(
245245
return;
246246
}
247247

248-
if (!completion.subCtx.isUpgrade) {
248+
if (!completion.subCtx.isUpgrade && !completion.subCtx.isPaidCurrencyChange) {
249249
throw PayKitError.from(
250250
"BAD_REQUEST",
251251
PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID,
252-
`Checkout completion is only valid for new paid subscriptions or upgrades to "${completion.subCtx.storedPlan.id}"`,
252+
`Checkout completion is only valid for new paid subscriptions, upgrades, or cross-currency changes to "${completion.subCtx.storedPlan.id}"`,
253253
);
254254
}
255255

packages/paykit/src/types/schema.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
22

33
import * as z from "zod";
44

5+
import { DEFAULT_STRIPE_CURRENCY } from "../stripe/currency";
6+
57
const payKitFeatureSymbol = Symbol.for("paykit.feature");
68
const payKitFeatureIncludeSymbol = Symbol.for("paykit.feature_include");
79
const payKitPlanSymbol = Symbol.for("paykit.plan");
@@ -429,7 +431,7 @@ export function normalizeSchema(
429431
isDefault,
430432
name: exportedPlan.name ?? deriveNameFromId(exportedPlan.id),
431433
priceAmount: exportedPlan.price ? Math.round(exportedPlan.price.amount * 100) : null,
432-
priceCurrency: exportedPlan.price ? (input?.priceCurrency ?? "usd") : null,
434+
priceCurrency: exportedPlan.price ? (input?.priceCurrency ?? DEFAULT_STRIPE_CURRENCY) : null,
433435
priceInterval: exportedPlan.price?.interval ?? null,
434436
trialDays: null,
435437
};

0 commit comments

Comments
 (0)