mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 04:30:38 +00:00
* 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>
233 lines
9.5 KiB
TypeScript
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 };
|
|
}
|