mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Adds support / goodwill tooling for granting free-tier credits that
survive Dodo subscription cancellations. Triggered by the 2026-04-17/18
duplicate-subscription incident: the customer was granted a manual
extension to validUntil, but that extension is naked — our existing
handleSubscriptionExpired handler unconditionally downgrades to free
when Dodo fires the expired event, which would wipe the credit.
Three coordinated changes:
1. convex/schema.ts — add optional compUntil: number to entitlements.
Acts as a "don't downgrade me before this" floor independent of the
subscription billing cycle. Optional, so existing rows are untouched.
2. convex/payments/billing.ts::grantComplimentaryEntitlement —
new internalMutation callable via `npx convex run`. Upserts the
entitlement, sets both validUntil and compUntil to max(existing, now +
days). Never shrinks (calling twice is idempotent upward), validates
planKey against PRODUCT_CATALOG, and syncs the Redis cache so edge
gateway sees the comp without waiting for TTL.
3. convex/payments/subscriptionHelpers.ts::handleSubscriptionExpired —
before the unconditional downgrade, read the current entitlement and
skip revocation if compUntil > eventTimestamp. This protects comp
grants from Dodo-originated revocations; normal subscription.expired
revocation is unchanged when there's no comp or the comp has lapsed.
Tests (convex/__tests__/comp-entitlement.test.ts, 9 new):
grantComplimentaryEntitlement
creates row with validUntil == compUntil
never shrinks an existing longer comp
upgrades planKey on existing free-tier row
rejects unknown planKey and non-positive days
handleSubscriptionExpired comp guard
revokes to free when no comp set (unchanged)
revokes to free when comp is already expired
preserves entitlement when comp is still valid
end-to-end: grant + expired webhook = entitlement survives
CLI usage (requires npx convex deploy after merge, then):
npx convex run 'payments/billing:grantComplimentaryEntitlement' \
'{"userId":"user_XXX","planKey":"pro_monthly","days":90,"reason":"support"}'
Full check suite green: typecheck x2, biome, 5590 data tests,
171 edge tests, 9 convex tests, md lint, version sync.
241 lines
7.4 KiB
TypeScript
241 lines
7.4 KiB
TypeScript
import { defineSchema, defineTable } from "convex/server";
|
|
import { v } from "convex/values";
|
|
import { channelTypeValidator, digestModeValidator, quietHoursOverrideValidator, sensitivityValidator } from "./constants";
|
|
|
|
// Subscription status enum — maps Dodo statuses to our internal set
|
|
const subscriptionStatus = v.union(
|
|
v.literal("active"),
|
|
v.literal("on_hold"),
|
|
v.literal("cancelled"),
|
|
v.literal("expired"),
|
|
);
|
|
|
|
// Payment event status enum — covers charge outcomes and dispute lifecycle
|
|
const paymentEventStatus = v.union(
|
|
v.literal("succeeded"),
|
|
v.literal("failed"),
|
|
v.literal("dispute_opened"),
|
|
v.literal("dispute_won"),
|
|
v.literal("dispute_lost"),
|
|
v.literal("dispute_closed"),
|
|
);
|
|
|
|
export default defineSchema({
|
|
userPreferences: defineTable({
|
|
userId: v.string(),
|
|
variant: v.string(),
|
|
data: v.any(),
|
|
schemaVersion: v.number(),
|
|
updatedAt: v.number(),
|
|
syncVersion: v.number(),
|
|
}).index("by_user_variant", ["userId", "variant"]),
|
|
|
|
notificationChannels: defineTable(
|
|
v.union(
|
|
v.object({
|
|
userId: v.string(),
|
|
channelType: v.literal("telegram"),
|
|
chatId: v.string(),
|
|
verified: v.boolean(),
|
|
linkedAt: v.number(),
|
|
}),
|
|
v.object({
|
|
userId: v.string(),
|
|
channelType: v.literal("slack"),
|
|
webhookEnvelope: v.string(),
|
|
verified: v.boolean(),
|
|
linkedAt: v.number(),
|
|
slackChannelName: v.optional(v.string()),
|
|
slackTeamName: v.optional(v.string()),
|
|
slackConfigurationUrl: v.optional(v.string()),
|
|
}),
|
|
v.object({
|
|
userId: v.string(),
|
|
channelType: v.literal("email"),
|
|
email: v.string(),
|
|
verified: v.boolean(),
|
|
linkedAt: v.number(),
|
|
}),
|
|
v.object({
|
|
userId: v.string(),
|
|
channelType: v.literal("discord"),
|
|
webhookEnvelope: v.string(),
|
|
verified: v.boolean(),
|
|
linkedAt: v.number(),
|
|
discordGuildId: v.optional(v.string()),
|
|
discordChannelId: v.optional(v.string()),
|
|
}),
|
|
v.object({
|
|
userId: v.string(),
|
|
channelType: v.literal("webhook"),
|
|
webhookEnvelope: v.string(),
|
|
verified: v.boolean(),
|
|
linkedAt: v.number(),
|
|
webhookLabel: v.optional(v.string()),
|
|
webhookSecret: v.optional(v.string()),
|
|
}),
|
|
),
|
|
)
|
|
.index("by_user", ["userId"])
|
|
.index("by_user_channel", ["userId", "channelType"]),
|
|
|
|
alertRules: defineTable({
|
|
userId: v.string(),
|
|
variant: v.string(),
|
|
enabled: v.boolean(),
|
|
eventTypes: v.array(v.string()),
|
|
sensitivity: sensitivityValidator,
|
|
channels: v.array(channelTypeValidator),
|
|
updatedAt: v.number(),
|
|
quietHoursEnabled: v.optional(v.boolean()),
|
|
quietHoursStart: v.optional(v.number()),
|
|
quietHoursEnd: v.optional(v.number()),
|
|
quietHoursTimezone: v.optional(v.string()),
|
|
quietHoursOverride: v.optional(quietHoursOverrideValidator),
|
|
// Digest mode fields (absent = realtime, same as digestMode: "realtime")
|
|
digestMode: v.optional(digestModeValidator),
|
|
digestHour: v.optional(v.number()), // 0-23 local hour for daily/twice_daily
|
|
digestTimezone: v.optional(v.string()), // IANA timezone, e.g. "America/New_York"
|
|
aiDigestEnabled: v.optional(v.boolean()), // opt-in AI executive summary in digests (default true for new rules)
|
|
})
|
|
.index("by_user", ["userId"])
|
|
.index("by_user_variant", ["userId", "variant"])
|
|
.index("by_enabled", ["enabled"]),
|
|
|
|
telegramPairingTokens: defineTable({
|
|
userId: v.string(),
|
|
token: v.string(),
|
|
expiresAt: v.number(),
|
|
used: v.boolean(),
|
|
variant: v.optional(v.string()),
|
|
})
|
|
.index("by_token", ["token"])
|
|
.index("by_user", ["userId"]),
|
|
|
|
registrations: defineTable({
|
|
email: v.string(),
|
|
normalizedEmail: v.string(),
|
|
registeredAt: v.number(),
|
|
source: v.optional(v.string()),
|
|
appVersion: v.optional(v.string()),
|
|
referralCode: v.optional(v.string()),
|
|
referredBy: v.optional(v.string()),
|
|
referralCount: v.optional(v.number()),
|
|
})
|
|
.index("by_normalized_email", ["normalizedEmail"])
|
|
.index("by_referral_code", ["referralCode"]),
|
|
|
|
contactMessages: defineTable({
|
|
name: v.string(),
|
|
email: v.string(),
|
|
organization: v.optional(v.string()),
|
|
phone: v.optional(v.string()),
|
|
message: v.optional(v.string()),
|
|
source: v.string(),
|
|
receivedAt: v.number(),
|
|
}),
|
|
|
|
counters: defineTable({
|
|
name: v.string(),
|
|
value: v.number(),
|
|
}).index("by_name", ["name"]),
|
|
|
|
// --- Payment tables (Dodo Payments integration) ---
|
|
|
|
subscriptions: defineTable({
|
|
userId: v.string(),
|
|
dodoSubscriptionId: v.string(),
|
|
dodoProductId: v.string(),
|
|
planKey: v.string(),
|
|
status: subscriptionStatus,
|
|
currentPeriodStart: v.number(),
|
|
currentPeriodEnd: v.number(),
|
|
cancelledAt: v.optional(v.number()),
|
|
rawPayload: v.any(),
|
|
updatedAt: v.number(),
|
|
})
|
|
.index("by_userId", ["userId"])
|
|
.index("by_dodoSubscriptionId", ["dodoSubscriptionId"]),
|
|
|
|
entitlements: defineTable({
|
|
userId: v.string(),
|
|
planKey: v.string(),
|
|
features: v.object({
|
|
tier: v.number(),
|
|
maxDashboards: v.number(),
|
|
apiAccess: v.boolean(),
|
|
apiRateLimit: v.number(),
|
|
prioritySupport: v.boolean(),
|
|
exportFormats: v.array(v.string()),
|
|
}),
|
|
validUntil: v.number(),
|
|
// Optional complimentary-entitlement floor. When set and in the future,
|
|
// subscription.expired events skip the normal downgrade-to-free so
|
|
// goodwill credits outlive Dodo subscription cancellations.
|
|
compUntil: v.optional(v.number()),
|
|
updatedAt: v.number(),
|
|
}).index("by_userId", ["userId"]),
|
|
|
|
customers: defineTable({
|
|
userId: v.string(),
|
|
dodoCustomerId: v.optional(v.string()),
|
|
email: v.string(),
|
|
createdAt: v.number(),
|
|
updatedAt: v.number(),
|
|
})
|
|
.index("by_userId", ["userId"])
|
|
.index("by_dodoCustomerId", ["dodoCustomerId"]),
|
|
|
|
webhookEvents: defineTable({
|
|
webhookId: v.string(),
|
|
eventType: v.string(),
|
|
rawPayload: v.any(),
|
|
processedAt: v.number(),
|
|
status: v.literal("processed"),
|
|
})
|
|
.index("by_webhookId", ["webhookId"])
|
|
.index("by_eventType", ["eventType"]),
|
|
|
|
paymentEvents: defineTable({
|
|
userId: v.string(),
|
|
dodoPaymentId: v.string(),
|
|
type: v.union(v.literal("charge"), v.literal("refund")),
|
|
amount: v.number(),
|
|
currency: v.string(),
|
|
status: paymentEventStatus,
|
|
dodoSubscriptionId: v.optional(v.string()),
|
|
rawPayload: v.any(),
|
|
occurredAt: v.number(),
|
|
})
|
|
.index("by_userId", ["userId"])
|
|
.index("by_dodoPaymentId", ["dodoPaymentId"]),
|
|
|
|
productPlans: defineTable({
|
|
dodoProductId: v.string(),
|
|
planKey: v.string(),
|
|
displayName: v.string(),
|
|
isActive: v.boolean(),
|
|
})
|
|
.index("by_dodoProductId", ["dodoProductId"])
|
|
.index("by_planKey", ["planKey"]),
|
|
|
|
userApiKeys: defineTable({
|
|
userId: v.string(),
|
|
name: v.string(),
|
|
keyPrefix: v.string(), // first 8 chars of plaintext key, for display
|
|
keyHash: v.string(), // SHA-256 hex digest — never store plaintext
|
|
createdAt: v.number(),
|
|
lastUsedAt: v.optional(v.number()),
|
|
revokedAt: v.optional(v.number()),
|
|
})
|
|
.index("by_userId", ["userId"])
|
|
.index("by_keyHash", ["keyHash"]),
|
|
|
|
emailSuppressions: defineTable({
|
|
normalizedEmail: v.string(),
|
|
reason: v.union(v.literal("bounce"), v.literal("complaint"), v.literal("manual")),
|
|
suppressedAt: v.number(),
|
|
source: v.optional(v.string()),
|
|
}).index("by_normalized_email", ["normalizedEmail"]),
|
|
});
|