128 lines
4.6 KiB
TypeScript
128 lines
4.6 KiB
TypeScript
import {
|
||
boolean,
|
||
index,
|
||
integer,
|
||
jsonb,
|
||
pgTable,
|
||
text,
|
||
timestamp,
|
||
uniqueIndex,
|
||
uuid,
|
||
} from "drizzle-orm/pg-core";
|
||
import { sql } from "drizzle-orm";
|
||
import {
|
||
createdAt,
|
||
subscriptionPlanEnum,
|
||
subscriptionProviderEnum,
|
||
subscriptionStatusEnum,
|
||
updatedAt,
|
||
} from "./_shared.js";
|
||
import { households } from "./households.js";
|
||
import { users } from "./users.js";
|
||
|
||
/** Backend är source of truth för Premium (spec §47, §61.14). */
|
||
export const subscriptions = pgTable(
|
||
"subscriptions",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
userId: uuid("user_id")
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: "cascade" }),
|
||
householdId: uuid("household_id").references(() => households.id, { onDelete: "set null" }),
|
||
provider: subscriptionProviderEnum("provider").notNull(),
|
||
productId: text("product_id").notNull(),
|
||
plan: subscriptionPlanEnum("plan").notNull(),
|
||
originalTransactionId: text("original_transaction_id"),
|
||
status: subscriptionStatusEnum("status").notNull(),
|
||
purchasedAt: timestamp("purchased_at", { withTimezone: true }),
|
||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||
gracePeriodExpiresAt: timestamp("grace_period_expires_at", { withTimezone: true }),
|
||
canceledAt: timestamp("canceled_at", { withTimezone: true }),
|
||
lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true }),
|
||
createdAt: createdAt(),
|
||
updatedAt: updatedAt(),
|
||
},
|
||
(t) => [
|
||
index("subscriptions_user_idx").on(t.userId),
|
||
uniqueIndex("subscriptions_original_tx_unique")
|
||
.on(t.provider, t.originalTransactionId)
|
||
.where(sql`${t.originalTransactionId} IS NOT NULL`),
|
||
],
|
||
);
|
||
|
||
/** Händelselogg: köp, förnyelse, grace, churn (spec §47). */
|
||
export const subscriptionEvents = pgTable(
|
||
"subscription_events",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
subscriptionId: uuid("subscription_id").references(() => subscriptions.id, {
|
||
onDelete: "cascade",
|
||
}),
|
||
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
|
||
eventType: text("event_type").notNull(),
|
||
payload: jsonb("payload"),
|
||
createdAt: createdAt(),
|
||
},
|
||
(t) => [index("subscription_events_sub_idx").on(t.subscriptionId, t.createdAt)],
|
||
);
|
||
|
||
/** Råa store-transaktioner för revision (spec §47). */
|
||
export const storeTransactions = pgTable(
|
||
"store_transactions",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
provider: subscriptionProviderEnum("provider").notNull(),
|
||
transactionId: text("transaction_id").notNull(),
|
||
originalTransactionId: text("original_transaction_id"),
|
||
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
|
||
productId: text("product_id"),
|
||
rawPayload: jsonb("raw_payload").notNull(),
|
||
processedAt: timestamp("processed_at", { withTimezone: true }),
|
||
createdAt: createdAt(),
|
||
},
|
||
(t) => [uniqueIndex("store_transactions_unique").on(t.provider, t.transactionId)],
|
||
);
|
||
|
||
/** App Store Server Notifications / Play RTDN – tas emot rått, processas av worker. */
|
||
export const storeNotifications = pgTable(
|
||
"store_notifications",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
provider: subscriptionProviderEnum("provider").notNull(),
|
||
notificationType: text("notification_type"),
|
||
rawPayload: jsonb("raw_payload").notNull(),
|
||
signatureVerified: boolean("signature_verified").notNull().default(false),
|
||
processed: boolean("processed").notNull().default(false),
|
||
processedAt: timestamp("processed_at", { withTimezone: true }),
|
||
error: text("error"),
|
||
createdAt: createdAt(),
|
||
},
|
||
(t) => [index("store_notifications_pending_idx").on(t.processed, t.createdAt)],
|
||
);
|
||
|
||
/** Trial-spårning: en trial per användare (spec §46). */
|
||
export const trials = pgTable("trials", {
|
||
userId: uuid("user_id")
|
||
.primaryKey()
|
||
.references(() => users.id, { onDelete: "cascade" }),
|
||
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
||
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
|
||
});
|
||
|
||
/** Månatlig AI-användning för fair use / gratiskvot (spec §45–46). */
|
||
export const aiUsageCounters = pgTable(
|
||
"ai_usage_counters",
|
||
{
|
||
userId: uuid("user_id")
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: "cascade" }),
|
||
/** Format YYYY-MM. */
|
||
month: text("month").notNull(),
|
||
aiScans: integer("ai_scans").notNull().default(0),
|
||
aiTokensIn: integer("ai_tokens_in").notNull().default(0),
|
||
aiTokensOut: integer("ai_tokens_out").notNull().default(0),
|
||
updatedAt: updatedAt(),
|
||
},
|
||
(t) => [uniqueIndex("ai_usage_user_month_unique").on(t.userId, t.month)],
|
||
);
|