Files
simplex-chat/apps/simplex-badge-service/web/src/order.ts
T
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

233 lines
9.5 KiB
TypeScript

import { SINGLE_MONTH } from "./catalog.js";
import { composeOrder, definedOnly } from "./domain.js";
import { screenForOpenOrder } from "./routing.js";
import type { InvoiceView } from "./api.js";
import type { Chain, Method, OrderLocalState, OrderRecord, OrderServerState, OrderStatus } from "./domain.js";
import type { Store } from "./store.js";
export type UnpaidOrder = Omit<OrderRecord, "code">;
// This strips the code by value, since an extra property survives a widening cast.
export function withoutCode(record: OrderRecord): UnpaidOrder {
const { code: _code, ...rest } = record;
return rest;
}
export function withoutDestination(record: OrderRecord): OrderRecord {
return definedOnly({
...record,
address: undefined, cryptoAmount: undefined, cryptoCurrency: undefined, expiresAt: undefined,
});
}
export type PaymentView =
| { screen: "unknownOrder"; orderId: string }
| { screen: "codeIssued"; order: OrderRecord; invoice: InvoiceView | undefined; code: string; savedLocally: boolean }
| { screen: "paidNoCode"; order: UnpaidOrder; invoice: InvoiceView | undefined }
| { screen: "awaitingPayment"; order: UnpaidOrder; invoice: InvoiceView; method: Chain; resumed: boolean }
| { screen: "awaitingConfirmation"; order: UnpaidOrder; invoice: InvoiceView | undefined; gaveUp: boolean; method?: Method }
| { screen: "windowClosed"; order: UnpaidOrder; invoice: InvoiceView | undefined }
| { screen: "cardForm"; order: UnpaidOrder; invoice: InvoiceView; clientSecret: string; resumed: boolean }
| { screen: "detailsUnavailable"; order: UnpaidOrder };
export interface PaymentProgress {
status: OrderStatus;
cryptoAmountPaid?: string | undefined;
paidInFull?: boolean | undefined;
}
export type HistoryRow =
| { kind: "paid"; order: UnpaidOrder; code: string }
| { kind: "paidNoCode"; order: UnpaidOrder }
| { kind: "open"; order: UnpaidOrder }
| { kind: "partPaid"; order: UnpaidOrder }
| { kind: "processing"; order: UnpaidOrder }
| { kind: "canceled"; order: UnpaidOrder }
| { kind: "expired"; order: UnpaidOrder };
export function historyRows(entries: readonly OrderRecord[]): HistoryRow[] {
return entries.map((e) => {
const order = withoutCode(e);
// A canceled order wins over the expired or open status the server left behind, but a paid order
// keeps its code even where a cancel lost to settlement.
switch (orderPhase(e)) {
case "paid":
return e.code !== undefined ? { kind: "paid" as const, order, code: e.code } : { kind: "paidNoCode" as const, order };
case "expired":
return e.canceled === true ? { kind: "canceled" as const, order } : { kind: "expired" as const, order };
case "processing":
return { kind: "processing" as const, order };
case "partPaid":
return { kind: "partPaid" as const, order };
case "awaiting":
return e.canceled === true ? { kind: "canceled" as const, order } : { kind: "open" as const, order };
}
});
}
export function recordFromView(orderId: string, view: InvoiceView, nowMs: number): OrderRecord {
return {
orderId,
badgeType: view.badgeType ?? "",
months: view.months ?? 0,
createdAt: new Date(nowMs).toISOString(),
status: view.status,
};
}
interface Destination {
address: string;
cryptoAmount: string;
cryptoCurrency: Chain;
expiresAt: string | undefined;
}
interface Price {
amount: number;
currency: string;
}
function destinationOf(from: Partial<OrderServerState> | undefined): Destination | undefined {
const { address, cryptoAmount, cryptoCurrency, expiresAt } = from ?? {};
if (address === undefined || cryptoAmount === undefined || cryptoCurrency === undefined) return undefined;
return { address, cryptoAmount, cryptoCurrency, expiresAt };
}
type Paid = Pick<OrderServerState, "amountPaid" | "cryptoAmountPaid" | "cryptoAmountDue" | "paidInFull">;
function paidOf(from: Partial<OrderServerState> | undefined): Paid {
return {
amountPaid: from?.amountPaid,
cryptoAmountPaid: from?.cryptoAmountPaid,
cryptoAmountDue: from?.cryptoAmountDue,
paidInFull: from?.paidInFull,
};
}
function priceOf(from: Partial<OrderServerState> | undefined): Price | undefined {
const { amount, currency } = from ?? {};
return amount !== undefined && currency !== undefined ? { amount, currency } : undefined;
}
function stateOf(status: OrderStatus, price: Price | undefined, paid: Paid, to: Destination | undefined): OrderServerState {
return {
status,
amount: price?.amount,
currency: price?.currency,
...paid,
address: to?.address,
cryptoAmount: to?.cryptoAmount,
cryptoCurrency: to?.cryptoCurrency,
expiresAt: to?.expiresAt,
};
}
// Paid never falls back to the stored value, since the service withdrawing it means it is no longer true.
export function serverState(view: InvoiceView, held: OrderRecord | undefined): OrderServerState {
const fresh = destinationOf(view);
const stored = destinationOf(held);
const to = view.status !== "open" ? undefined
: fresh === undefined ? stored
: { ...fresh, expiresAt: fresh.expiresAt ?? stored?.expiresAt };
return stateOf(view.status, priceOf(view) ?? priceOf(held), paidOf(view), to);
}
function localState(base: OrderRecord, memory: OrderRecord | undefined, method: Method | undefined): OrderLocalState {
return {
code: memory?.code ?? base.code,
submitted: memory?.submitted === true || base.submitted === true ? true : undefined,
canceled: memory?.canceled === true || base.canceled === true ? true : undefined,
method: method ?? base.method,
};
}
export function offlineInvoice(record: OrderRecord): InvoiceView | undefined {
if (record.status !== "open") return undefined;
const to = destinationOf(record);
if (to === undefined) return undefined;
return definedOnly(stateOf("open", priceOf(record), paidOf(record), to));
}
export function closedInvoice(record: OrderRecord): InvoiceView | undefined {
if (record.status === "open") return undefined;
return definedOnly(stateOf(record.status, priceOf(record), paidOf(record), undefined));
}
export function applyView(
store: Store,
orderId: string,
view: InvoiceView,
nowMs: number,
memory?: OrderRecord,
method?: Method,
): { record: OrderRecord; savedLocally: boolean } {
const base = store.order(orderId) ?? memory ?? recordFromView(orderId, view, nowMs);
const record = composeOrder(base, localState(base, memory, method), serverState(view, base));
store.saveOrder(record);
const savedLocally = store.holdsCode(orderId, record.code);
return { record, savedLocally };
}
export function viewFor(
record: OrderRecord,
invoice: InvoiceView | undefined,
method: Method | undefined,
opts: { savedLocally: boolean; resumed: boolean },
): PaymentView {
const order = withoutCode(record);
const resumed = opts.resumed;
const unavailable = { screen: "detailsUnavailable", order } as const;
// The payment is read from the invoice when present, since a held response knows about a payment
// before the record is written from it.
const seen = invoice ?? record;
switch (orderPhase({ status: record.status, cryptoAmountPaid: seen.cryptoAmountPaid, paidInFull: seen.paidInFull })) {
case "paid":
return record.code !== undefined
? { screen: "codeIssued", order: record, invoice, code: record.code, savedLocally: opts.savedLocally }
: { screen: "paidNoCode", order, invoice };
case "expired":
return { screen: "windowClosed", order, invoice };
case "processing":
return method === undefined
? unavailable
: { screen: "awaitingConfirmation", order, invoice, gaveUp: false, method };
case "awaiting":
case "partPaid": {
if (method === undefined) return unavailable;
const screen = screenForOpenOrder(record, method);
if (screen === "awaitingConfirmation") return { screen, order, invoice, gaveUp: false, method };
if (invoice === undefined) return unavailable;
if (screen === "cardForm") {
const clientSecret = invoice.clientSecret;
return clientSecret === undefined ? unavailable : { screen, order, invoice, clientSecret, resumed };
}
if (method === "card" || invoice.address === undefined || invoice.cryptoAmount === undefined) return unavailable;
return { screen: "awaitingPayment", order, invoice, method, resumed };
}
}
}
export type OrderPhase = "awaiting" | "partPaid" | "processing" | "paid" | "expired";
export function orderPhase(o: PaymentProgress): OrderPhase {
if (o.status === "paid") return "paid";
if (o.status === "expired") return "expired";
// Monero reports a payment as confirming while the paid amount is still zero, so the paidInFull
// verdict must be checked before any figure.
if (o.paidInFull === true) return "processing";
return o.cryptoAmountPaid === undefined ? "awaiting" : "partPaid";
}
export function selectionFromOrder(
order: Pick<OrderRecord, "badgeType" | "months"> | undefined,
prices: readonly { priceId: string; badgeType: string }[],
offers: readonly { offerId: string; priceId: string; months: number }[],
): { priceId: string; offerId: string } | undefined {
if (order === undefined) return undefined;
const price = prices.find((p) => p.badgeType === order.badgeType);
if (price === undefined) return undefined;
if (order.months === 1) return { priceId: price.priceId, offerId: SINGLE_MONTH };
const offer = offers.find((o) => o.priceId === price.priceId && o.months === order.months);
return offer === undefined ? undefined : { priceId: price.priceId, offerId: offer.offerId };
}