## Why Stripe checkout used Stripe-only tables. This extracts a shared payment CORE. New checkout writes `payment_order`. Old Stripe tables stay so in-progress Sessions can still settle. This PR is [1/2]. [#2368](https://github.com/moeru-ai/airi/pull/2368) is [2/2]. That PR archives leftover Stripe tables after in-progress Sessions finish or expire. ## Changes - Add `payment_order` and `provider_account`. - Copy `stripe_checkout_session` into `payment_order`. - Copy `stripe_customer` into `provider_account`. - Keep `stripe_*` tables and `user_flux.stripe_customer_id`. - New checkout writes `payment_order` and stores `metadata.payment_order_id`. - Webhook resolves new Sessions by `metadata.payment_order_id`. - Webhook resolves older Sessions by `provider_order_id`, then by a leftover `stripe_checkout_session` row. - That leftover-row lookup covers Sessions opened before this deploy, and rows written while 0023 runs. [#2368](https://github.com/moeru-ai/airi/pull/2368) deletes it after those Sessions finish or expire. ## Test plan - [x] `pnpm exec vitest run server/apps/api/src/services/domain/payment/tests/payment.test.ts server/apps/api/src/routes/stripe` - [x] `pnpm -F @proj-airi/api-server typecheck` - [x] `git diff --check` ## Visual changes No user-visible changes. ## Open: settle after account deletion Account deletion stamps `payment_order.deletedAt`. A Checkout Session that is still open can still pay after that stamp. This PR keeps `main` behavior. `settle` loads the row by id, including a soft-deleted row, then marks it `paid` and credits Flux. Follow-up policy: if `deletedAt` is set, skip. Do not update the archive row. Do not credit Flux. Do not insert a live `provider_account`. Stripe remains the payment record. Skip matches the soft-delete rule: a deleted row is gone, not write it again. Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: RainbowBird <git@luoling.moe>
114 lines
3.9 KiB
SQL
114 lines
3.9 KiB
SQL
CREATE TABLE "payment_order" (
|
|
"id" text PRIMARY KEY NOT NULL,
|
|
"user_id" text NOT NULL,
|
|
"processor" text NOT NULL,
|
|
"processor_order_id" text,
|
|
"status" text NOT NULL,
|
|
"amount" integer,
|
|
"currency" text,
|
|
"pack_key" text,
|
|
"flux_amount" bigint,
|
|
"credited_at" timestamp,
|
|
"processor_data" jsonb,
|
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
|
"deleted_at" timestamp
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE TABLE "payment_customer" (
|
|
"id" text PRIMARY KEY NOT NULL,
|
|
"user_id" text NOT NULL,
|
|
"processor" text NOT NULL,
|
|
"customer_id" text NOT NULL,
|
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
|
"deleted_at" timestamp
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE UNIQUE INDEX "payment_order_processor_order_uidx" ON "payment_order" USING btree ("processor","processor_order_id") WHERE processor_order_id IS NOT NULL;--> statement-breakpoint
|
|
CREATE INDEX "payment_order_user_id_idx" ON "payment_order" USING btree ("user_id");--> statement-breakpoint
|
|
CREATE UNIQUE INDEX "payment_customer_processor_customer_uidx" ON "payment_customer" USING btree ("processor","customer_id") WHERE deleted_at IS NULL;--> statement-breakpoint
|
|
CREATE UNIQUE INDEX "payment_customer_processor_user_uidx" ON "payment_customer" USING btree ("processor","user_id") WHERE deleted_at IS NULL;--> statement-breakpoint
|
|
CREATE INDEX "payment_customer_user_id_idx" ON "payment_customer" USING btree ("user_id");--> statement-breakpoint
|
|
-- Keep stripe_* tables and user_flux.stripe_customer_id.
|
|
-- The previous process still uses them while this migration runs.
|
|
-- stripe_customer allowed several live rows per user. Copy the oldest live row and all deleted rows.
|
|
INSERT INTO "payment_customer" ("id", "user_id", "processor", "customer_id", "created_at", "updated_at", "deleted_at")
|
|
SELECT "id", "user_id", 'stripe', "stripe_customer_id", "created_at", "updated_at", "deleted_at"
|
|
FROM "stripe_customer"
|
|
WHERE "deleted_at" IS NOT NULL
|
|
UNION ALL
|
|
SELECT "id", "user_id", 'stripe', "stripe_customer_id", "created_at", "updated_at", "deleted_at"
|
|
FROM (
|
|
SELECT DISTINCT ON ("user_id") *
|
|
FROM "stripe_customer"
|
|
WHERE "deleted_at" IS NULL
|
|
ORDER BY "user_id", "created_at" ASC, "id" ASC
|
|
) live;--> statement-breakpoint
|
|
-- Copy checkout sessions so webhook retries can find them by Stripe session id.
|
|
-- flux_credited rows become paid so settle does not credit Flux again.
|
|
-- Old ledger request_id is the Stripe event id, not payment_order.id.
|
|
INSERT INTO "payment_order" (
|
|
"id",
|
|
"user_id",
|
|
"processor",
|
|
"processor_order_id",
|
|
"status",
|
|
"amount",
|
|
"currency",
|
|
"pack_key",
|
|
"flux_amount",
|
|
"credited_at",
|
|
"processor_data",
|
|
"created_at",
|
|
"updated_at",
|
|
"deleted_at"
|
|
)
|
|
SELECT
|
|
"id",
|
|
"user_id",
|
|
'stripe',
|
|
"stripe_session_id",
|
|
CASE
|
|
WHEN "flux_credited" THEN 'paid'
|
|
WHEN "status" = 'expired' THEN 'expired'
|
|
ELSE 'pending'
|
|
END,
|
|
"amount_total",
|
|
"currency",
|
|
CASE
|
|
WHEN "metadata" IS NOT NULL AND btrim("metadata") LIKE '{%' THEN "metadata"::jsonb->>'packKey'
|
|
ELSE NULL
|
|
END,
|
|
CASE
|
|
WHEN "metadata" IS NOT NULL AND btrim("metadata") LIKE '{%' AND ("metadata"::jsonb->>'fluxAmount') ~ '^-?[0-9]+$'
|
|
THEN ("metadata"::jsonb->>'fluxAmount')::bigint
|
|
ELSE NULL
|
|
END,
|
|
CASE
|
|
WHEN "flux_credited" THEN "updated_at"
|
|
ELSE NULL
|
|
END,
|
|
jsonb_strip_nulls(jsonb_build_object(
|
|
'stripeSessionId', "stripe_session_id",
|
|
'stripeCustomerId', "stripe_customer_id",
|
|
'mode', "mode",
|
|
'status', "status",
|
|
'paymentStatus', "payment_status",
|
|
'successUrl', "success_url",
|
|
'cancelUrl', "cancel_url",
|
|
'stripePaymentIntentId', "stripe_payment_intent_id",
|
|
'stripeSubscriptionId', "stripe_subscription_id",
|
|
'expiresAt', "expires_at",
|
|
'fluxCredited', "flux_credited",
|
|
'metadata', CASE
|
|
WHEN "metadata" IS NOT NULL AND btrim("metadata") LIKE '{%' THEN "metadata"::jsonb
|
|
WHEN "metadata" IS NOT NULL THEN to_jsonb("metadata")
|
|
ELSE NULL
|
|
END
|
|
)),
|
|
"created_at",
|
|
"updated_at",
|
|
"deleted_at"
|
|
FROM "stripe_checkout_session";
|