Files
Cibello-app/packages/database/src/schema/subscriptions.ts
T
Sven (AAMOS AI) ddd6b736ba feat(gdpr): fullständig raderingsflöde med pseudonym-retention, residual-test och hushållshantering
- Implementerar eraseUser() i packages/database/src/gdpr-erasure.ts som
  raderar/anonymiserar/pseudonymiserar alla personliga tabeller.
- DELETE /v1/me orkestrerar nu plan + transaktion + lagringsrensning.
- Enpersonshushåll raderas automatiskt; publika recept anonymiseras;
  draft/private-recept raderas.
- Finansiella poster (subscriptions/store_transactions/subscription_events)
  behålls under retention med slumpmässig pseudonym från
  gdpr_retention_pseudonyms.
- audit_logs/domain_events anonymiseras; kvitton i kvarvarande hushåll
  strippas på bild och OCR-text.
- Lägger till permanent residual-test (me.residual.test.ts) med
  schema-diff-assertion mot information_schema.
- Uppdaterar docs/23 och docs/29 med implementerad mekanism.
- Migration 0021 för gdpr_retention_pseudonyms och nullable
  cooking_sessions.started_by_user_id.
2026-08-08 05:45:49 +07:00

148 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
bigint,
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";
import { gdprRetentionPseudonyms } from "./gdpr.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").references(() => users.id, { onDelete: "set null" }),
/** Pseudonym used after GDPR deletion while financial records are retained. */
pseudonym: uuid("pseudonym").references(() => gdprRetentionPseudonyms.pseudonym, {
onDelete: "set null",
}),
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),
index("subscriptions_pseudonym_idx").on(t.pseudonym),
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" }),
/** Pseudonym used after GDPR deletion while records are retained. */
pseudonym: uuid("pseudonym").references(() => gdprRetentionPseudonyms.pseudonym, {
onDelete: "set null",
}),
eventType: text("event_type").notNull(),
payload: jsonb("payload"),
createdAt: createdAt(),
},
(t) => [
index("subscription_events_sub_idx").on(t.subscriptionId, t.createdAt),
index("subscription_events_pseudonym_idx").on(t.pseudonym),
],
);
/** 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" }),
/** Pseudonym used after GDPR deletion while records are retained. */
pseudonym: uuid("pseudonym").references(() => gdprRetentionPseudonyms.pseudonym, {
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),
index("store_transactions_pseudonym_idx").on(t.pseudonym),
],
);
/** 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 §4546). */
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),
aiCostUsdMicrocents: bigint("ai_cost_usd_microcents", { mode: "number" }).notNull().default(0),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex("ai_usage_user_month_unique").on(t.userId, t.month)],
);