Files
moeka-project/server/apps/api/drizzle/0023_payment_order.sql
T
37c837e502 refactor(api): extract payment CORE to support other payment providers [1/2] (#2335)
## 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>
2026-09-12 17:43:10 +08:00

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";