cascivo 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import{a as e,r as t}from"./config-DT-Cs7bC.mjs";import{i as n}from"./fs-B7O-fNID.mjs";import{n as r,r as i,t as a}from"./args-CtmltI1S.mjs";import{stdin as o,stdout as s}from"node:process";import{existsSync as c,readdirSync as l}from"node:fs";import{join as u}from"node:path";import{createInterface as d}from"node:readline/promises";const f={"@cascivo/react":`1.5.0`,"@cascivo/themes":`1.0.0`,"@cascivo/charts":`1.5.0`,"@cascivo/icons":`1.1.0`,"@cascivo/eslint-config":`0.4.1`,"@cascivo/app":`1.5.0`,"@cascivo/render":`1.5.0`,"@cascivo/storage":`1.5.0`,"@cascivo/email":`0.5.0`},p=`>=3.0.0`;function m(e){let t=e.startsWith(`/`)?e:`/${e}`;return t.length>1?t.replace(/\/+$/,``)||`/`:t}function h(e){return e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)}function g(e){let t=m(e).split(`/`).filter(Boolean),n=[],r=[],i=``;return t.forEach((a,o)=>{if(a===`*`){if(o!==t.length-1)throw Error(`"*" must be the last segment of a path pattern: "${e}"`);n.push(`*`),r.push(1),i+=`(?:/(.*))?`}else if(a.startsWith(`:`)){let t=a.slice(1);if(!/^[A-Za-z_$][\w$]*$/.test(t))throw Error(`Invalid param name "${t}" in path pattern "${e}"`);n.push(t),r.push(2),i+=`/([^/]+)`}else r.push(3),i+=`/${h(a)}`}),{pattern:e,keys:n,regex:RegExp(`^${i||`/`}/?$`),score:r}}function _(e,t){let n=Math.max(e.score.length,t.score.length);for(let r=0;r<n;r++){let n=(t.score[r]??0)-(e.score[r]??0);if(n!==0)return n}return 0}var v=/\.(tsx|jsx)$/;function y(e){if(!v.test(e)||/\.(test|spec)\.[jt]sx$/.test(e))return;let t=e.replace(v,``).split(`/`);if(!t.some(e=>e.startsWith(`_`)))return t.length===1&&t[0]===`404`?null:(t.at(-1)===`index`&&t.pop(),`/${t.map((n,r)=>{if(/^\[\.\.\.[A-Za-z_$][\w$]*\]$/.test(n)){if(r!==t.length-1)throw Error(`routes/${e}: a [...rest] segment must be last`);return`*`}let i=/^\[([A-Za-z_$][\w$]*)\]$/.exec(n);if(i)return`:${i[1]}`;if(/[[\]]/.test(n))throw Error(`routes/${e}: malformed segment "${n}"`);return n}).join(`/`)}`)}function b(e,t){let n=[],r=new Map;for(let t of e){let e=y(t);if(e===void 0)continue;let i=r.get(e);if(i)throw Error(`routes/${i} and routes/${t} both define ${e??`the 404 route`}`);r.set(e,t),n.push({file:t,path:e})}let i=e=>`${t}/${e.replace(v,``)}`,a=n.filter(e=>e.path!==null).map(e=>({...e,compiled:g(e.path)})).sort((e,t)=>_(e.compiled,t.compiled)||e.path.localeCompare(t.path)),o=n.find(e=>e.path===null);return[`// Generated by @cascivo/app/vite from the routes directory — do not edit.`,`// Add, rename or delete a route file and this file is rewritten.`,`import { lazyRoute } from '@cascivo/app'`,`import type { Route } from '@cascivo/app'`,``,`export const routes: Route[] = [`,...a.map(e=>` lazyRoute('${e.path}', () => import('${i(e.file)}')),`),`]`,``,o?`export const notFound: Route | undefined = lazyRoute('*', () => import('${i(o.file)}'))`:`export const notFound: Route | undefined = undefined`,``,"/** Every route pattern. Fill one with `buildPath` for a typed link. */",`export type AppPath = ${a.length>0?a.map(e=>`'${e.path}'`).join(` | `):`never`}`,``].join(`
1
+ import{a as e,r as t}from"./config-DT-Cs7bC.mjs";import{i as n}from"./fs-B7O-fNID.mjs";import{n as r,r as i,t as a}from"./args-CtmltI1S.mjs";import{stdin as o,stdout as s}from"node:process";import{existsSync as c,readdirSync as l}from"node:fs";import{join as u}from"node:path";import{createInterface as d}from"node:readline/promises";const f={"@cascivo/react":`1.6.0`,"@cascivo/themes":`1.0.1`,"@cascivo/charts":`1.6.0`,"@cascivo/icons":`1.1.0`,"@cascivo/eslint-config":`0.4.1`,"@cascivo/app":`1.6.0`,"@cascivo/render":`1.6.0`,"@cascivo/storage":`1.6.0`,"@cascivo/email":`0.5.0`},p=`>=3.0.0`;function m(e){let t=e.startsWith(`/`)?e:`/${e}`;return t.length>1?t.replace(/\/+$/,``)||`/`:t}function h(e){return e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)}function g(e){let t=m(e).split(`/`).filter(Boolean),n=[],r=[],i=``;return t.forEach((a,o)=>{if(a===`*`){if(o!==t.length-1)throw Error(`"*" must be the last segment of a path pattern: "${e}"`);n.push(`*`),r.push(1),i+=`(?:/(.*))?`}else if(a.startsWith(`:`)){let t=a.slice(1);if(!/^[A-Za-z_$][\w$]*$/.test(t))throw Error(`Invalid param name "${t}" in path pattern "${e}"`);n.push(t),r.push(2),i+=`/([^/]+)`}else r.push(3),i+=`/${h(a)}`}),{pattern:e,keys:n,regex:RegExp(`^${i||`/`}/?$`),score:r}}function _(e,t){let n=Math.max(e.score.length,t.score.length);for(let r=0;r<n;r++){let n=(t.score[r]??0)-(e.score[r]??0);if(n!==0)return n}return 0}var v=/\.(tsx|jsx)$/;function y(e){if(!v.test(e)||/\.(test|spec)\.[jt]sx$/.test(e))return;let t=e.replace(v,``).split(`/`);if(!t.some(e=>e.startsWith(`_`)))return t.length===1&&t[0]===`404`?null:(t.at(-1)===`index`&&t.pop(),`/${t.map((n,r)=>{if(/^\[\.\.\.[A-Za-z_$][\w$]*\]$/.test(n)){if(r!==t.length-1)throw Error(`routes/${e}: a [...rest] segment must be last`);return`*`}let i=/^\[([A-Za-z_$][\w$]*)\]$/.exec(n);if(i)return`:${i[1]}`;if(/[[\]]/.test(n))throw Error(`routes/${e}: malformed segment "${n}"`);return n}).join(`/`)}`)}function b(e,t){let n=[],r=new Map;for(let t of e){let e=y(t);if(e===void 0)continue;let i=r.get(e);if(i)throw Error(`routes/${i} and routes/${t} both define ${e??`the 404 route`}`);r.set(e,t),n.push({file:t,path:e})}let i=e=>`${t}/${e.replace(v,``)}`,a=n.filter(e=>e.path!==null).map(e=>({...e,compiled:g(e.path)})).sort((e,t)=>_(e.compiled,t.compiled)||e.path.localeCompare(t.path)),o=n.find(e=>e.path===null);return[`// Generated by @cascivo/app/vite from the routes directory — do not edit.`,`// Add, rename or delete a route file and this file is rewritten.`,`import { lazyRoute } from '@cascivo/app'`,`import type { Route } from '@cascivo/app'`,``,`export const routes: Route[] = [`,...a.map(e=>` lazyRoute('${e.path}', () => import('${i(e.file)}')),`),`]`,``,o?`export const notFound: Route | undefined = lazyRoute('*', () => import('${i(o.file)}'))`:`export const notFound: Route | undefined = undefined`,``,"/** Every route pattern. Fill one with `buildPath` for a typed link. */",`export type AppPath = ${a.length>0?a.map(e=>`'${e.path}'`).join(` | `):`never`}`,``].join(`
2
2
  `)}const x=f;function S(e){return e===`yarn`?`yarn`:`${e} install`}function C(e,t){return e===`npm`?`npm run ${t}`:`${e} ${t}`}function w(e,t){return`${e} run ${t}`}const T=[`react-vite`,`astro`,`cloudflare`],E=[`preact`,`react`],D=[`board`,`agent`,`notes`,`import`,`files`,`export`,`usage`,`crud`,`live`,`voice`,`publish`,`webhooks`,`digest`,`search`,`checkout`,`newsletter`];function O(e){return D.includes(e)}function k(e){return E.includes(e)}function A(e){return e.trim().toLowerCase().replace(/[^a-z0-9]+/g,`-`).replace(/^-+|-+$/g,``)||`section`}function j(e){let t=e.trim().split(/[^a-zA-Z0-9]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(``)||`Section`;return/^[0-9]/.test(t)?`Section${t}`:t}function M(e){let t=e.trim().split(/[^a-zA-Z0-9]+/).filter(e=>e!==``&&!/^\d+$/.test(e)&&!/^v\d+$/i.test(e)),n=[];for(let e of t){if(n.length>=3||n.length>0&&n.join(` `).length+1+e.length>24)break;n.push(e.charAt(0).toUpperCase()+e.slice(1))}return n.join(` `)||`App`}function N(e){return e.trim().toLowerCase().replace(/[^a-z0-9._-]+/g,`-`).replace(/^-+|-+$/g,``)||`cascivo-app`}function ee(e){let t=new Set,n=new Set,r=[];for(let i of e){let e=i.trim();if(!e)continue;let a=A(e),o=j(e),s=2;for(;t.has(a)||n.has(o);)a=`${A(e)}-${s}`,o=`${j(e)}${s}`,s++;t.add(a),n.add(o),r.push({key:a,label:e,component:o})}return r.length>0?r:[{key:`home`,label:`Home`,component:`Home`}]}function te(e){let t={name:N(e.name),private:!0,version:`0.0.0`,type:`module`,scripts:{dev:`vite`,build:`tsc && vite build`,preview:`vite preview`,typecheck:`tsc --noEmit`,lint:`eslint .`,format:`prettier --write .`,"format:check":`prettier --check .`},dependencies:{"@cascivo/react":x[`@cascivo/react`],"@cascivo/themes":x[`@cascivo/themes`],"@preact/signals-react":p,react:`^19.0.0`,"react-dom":`^19.0.0`},devDependencies:{"@cascivo/eslint-config":x[`@cascivo/eslint-config`],"@eslint/js":`^9.0.0`,"@types/react":`^19.0.0`,"@types/react-dom":`^19.0.0`,"@vitejs/plugin-react":`^5.0.0`,eslint:`^9.0.0`,"eslint-plugin-react-hooks":`^7.0.0`,prettier:`^3.0.0`,typescript:`^5.7.0`,"typescript-eslint":`^8.0.0`,vite:`^7.0.0`}};return JSON.stringify(t,null,2)+`
3
3
  `}function P(e){return JSON.stringify(e,null,2).replace(/\[\n\s+("[^"\n]*"(?:,\n\s+"[^"\n]*")*)\n\s*\]/g,(e,t)=>`[${t.split(/,\n\s+/).join(`, `)}]`)+`
4
4
  `}function F(){return P({compilerOptions:{target:`ES2022`,useDefineForClassFields:!0,lib:[`ES2022`,`DOM`,`DOM.Iterable`],module:`ESNext`,skipLibCheck:!0,moduleResolution:`bundler`,allowImportingTsExtensions:!0,resolveJsonModule:!0,isolatedModules:!0,moduleDetection:`force`,noEmit:!0,jsx:`react-jsx`,strict:!0,noUnusedLocals:!0,noUnusedParameters:!0,noFallthroughCasesInSwitch:!0},include:[`src`]})}function ne(){return`import react from '@vitejs/plugin-react'
@@ -1100,9 +1100,7 @@ ${C?` ${w?`async `:``}fetch(request: Request, env: Env): Promise<Response>${w?`
1100
1100
  // Stripe's webhook (worker/checkout.ts); a bad signature is a 401.
1101
1101
  if (checkoutPath === orderStore.STRIPE_WEBHOOK_PATH && request.method === 'POST') {
1102
1102
  try {
1103
- return await orderStore.receiveStripe(request, env${g?`, (id) =>
1104
- billingStore.syncSubscription(env, id),
1105
- `:``})
1103
+ return await orderStore.receiveStripe(request, env${g?`, billingStore.billingHooks(env)`:``})
1106
1104
  } catch (error) {
1107
1105
  return guardResponse(error)
1108
1106
  }
@@ -3793,8 +3791,12 @@ export const PRODUCT = {
3793
3791
  currency: 'eur',
3794
3792
  }
3795
3793
 
3796
- /** \`pending\` until Stripe confirms the payment; the other three are final. */
3797
- export type OrderStatus = 'pending' | 'paid' | 'failed' | 'expired'
3794
+ /**
3795
+ * \`pending\` until Stripe confirms the payment. \`failed\` and \`expired\` are final. A paid
3796
+ * order becomes \`refunded\` when all of it is refunded, and \`disputed\` while a chargeback is
3797
+ * open (back to \`paid\` if it is won).
3798
+ */
3799
+ export type OrderStatus = 'pending' | 'paid' | 'failed' | 'expired' | 'refunded' | 'disputed'
3798
3800
 
3799
3801
  export interface Order {
3800
3802
  id: string
@@ -3802,6 +3804,8 @@ export interface Order {
3802
3804
  /** What Stripe charged, in the currency's smallest unit. */
3803
3805
  amount: number
3804
3806
  currency: string
3807
+ /** Refunded so far, in the same unit: part of a paid order, or all of a refunded one. */
3808
+ refundedAmount: number
3805
3809
  createdAt: string
3806
3810
  paidAt: string | null
3807
3811
  }
@@ -3819,22 +3823,34 @@ export function formatPrice(amount: number, currency: string): string {
3819
3823
  return format.format(amount / 10 ** digits)
3820
3824
  }
3821
3825
 
3822
- const STATUSES = ['pending', 'paid', 'failed', 'expired']
3826
+ const STATUSES = ['pending', 'paid', 'failed', 'expired', 'refunded', 'disputed']
3823
3827
 
3824
3828
  export function parseOrder(raw: unknown): Order {
3825
3829
  if (typeof raw === 'object' && raw !== null) {
3826
- const { id, status, amount, currency, createdAt, paidAt } = raw as Record<string, unknown>
3830
+ const { id, status, amount, currency, refundedAmount, createdAt, paidAt } = raw as Record<
3831
+ string,
3832
+ unknown
3833
+ >
3827
3834
  if (
3828
3835
  typeof id === 'string' &&
3829
3836
  typeof status === 'string' &&
3830
3837
  STATUSES.includes(status) &&
3831
3838
  typeof amount === 'number' &&
3832
3839
  typeof currency === 'string' &&
3840
+ typeof refundedAmount === 'number' &&
3833
3841
  typeof createdAt === 'string' &&
3834
3842
  (paidAt === null || typeof paidAt === 'string')
3835
3843
  ) {
3836
- // Checked against STATUSES just above.
3837
- return { id, status: status as OrderStatus, amount, currency, createdAt, paidAt }
3844
+ return {
3845
+ id,
3846
+ // Checked against STATUSES just above.
3847
+ status: status as OrderStatus,
3848
+ amount,
3849
+ currency,
3850
+ refundedAmount,
3851
+ createdAt,
3852
+ paidAt,
3853
+ }
3838
3854
  }
3839
3855
  }
3840
3856
  throw new Error('Malformed order')
@@ -3852,7 +3868,15 @@ import { migrate, queryRows } from '@cascivo/app/db'
3852
3868
  import type { Database } from '@cascivo/app/db'
3853
3869
  import { verifyWebhook } from '@cascivo/app/guard'
3854
3870
  import { StripeError, createStripe, parseStripeEvent } from '@cascivo/app/stripe'
3855
- import type { CheckoutEventType, CheckoutSession, Stripe } from '@cascivo/app/stripe'
3871
+ import type {
3872
+ Charge,
3873
+ CheckoutEventType,
3874
+ CheckoutSession,
3875
+ Dispute,
3876
+ DisputeEventType,
3877
+ Invoice,
3878
+ Stripe,
3879
+ } from '@cascivo/app/stripe'
3856
3880
  import { writeRoom } from '@cascivo/app/sync-server'
3857
3881
  import type { RoomNamespace } from '@cascivo/app/sync-server'
3858
3882
  import { Receipt, receiptSubject, renderEmail } from '@cascivo/email'
@@ -3872,8 +3896,13 @@ const migrations = [
3872
3896
  currency TEXT NOT NULL,
3873
3897
  email TEXT,
3874
3898
  created_at TEXT NOT NULL,
3875
- paid_at TEXT
3899
+ paid_at TEXT,
3900
+ payment_intent_id TEXT,
3901
+ refunded_amount INTEGER NOT NULL DEFAULT 0,
3902
+ dispute_status TEXT
3876
3903
  )\`,
3904
+ // Refund and dispute events name the payment, not the session.
3905
+ 'CREATE INDEX orders_payment_intent ON orders (payment_intent_id)',
3877
3906
  ],
3878
3907
  },
3879
3908
  ]
@@ -3901,7 +3930,8 @@ export interface CheckoutEnv {
3901
3930
  STRIPE_WEBHOOK_SECRET?: string
3902
3931
  }
3903
3932
 
3904
- const COLUMNS = 'id, status, amount, currency, created_at AS createdAt, paid_at AS paidAt'
3933
+ const COLUMNS =
3934
+ 'id, status, amount, currency, refunded_amount AS refundedAmount, created_at AS createdAt, paid_at AS paidAt'
3905
3935
 
3906
3936
  export function stripeOf(env: { STRIPE_SECRET_KEY?: string }): Stripe {
3907
3937
  if (!env.STRIPE_SECRET_KEY) {
@@ -3996,7 +4026,7 @@ async function settle(
3996
4026
  const [order] = await queryRows(
3997
4027
  env.DB,
3998
4028
  \`UPDATE orders SET status = ?, paid_at = ?, amount = COALESCE(?, amount),
3999
- currency = COALESCE(?, currency), email = ?
4029
+ currency = COALESCE(?, currency), email = ?, payment_intent_id = ?
4000
4030
  WHERE session_id = ? AND status = 'pending' RETURNING \${COLUMNS}\`,
4001
4031
  [
4002
4032
  status,
@@ -4004,6 +4034,7 @@ async function settle(
4004
4034
  session.amountTotal,
4005
4035
  session.currency,
4006
4036
  session.customerEmail,
4037
+ session.paymentIntentId,
4007
4038
  session.id,
4008
4039
  ],
4009
4040
  parseOrder,
@@ -4051,16 +4082,83 @@ async function sendReceipt(env: CheckoutEnv, order: Order, to: string, origin: s
4051
4082
  }
4052
4083
  }
4053
4084
 
4085
+ /**
4086
+ * A refund, made in the Stripe dashboard or with \`createRefund\`. \`charge.refunded\` carries the
4087
+ * running total, so a retried or late event cannot count a refund twice. All of it refunded
4088
+ * makes the order \`refunded\`; part of it leaves it \`paid\`, with the amount shown.
4089
+ */
4090
+ async function refundOrder(env: CheckoutEnv, charge: Charge): Promise<void> {
4091
+ if (!charge.paymentIntentId) return
4092
+ await migrate(env.DB, migrations)
4093
+ const [order] = await queryRows(
4094
+ env.DB,
4095
+ \`UPDATE orders SET refunded_amount = MAX(refunded_amount, ?),
4096
+ status = CASE WHEN ? = 1 THEN 'refunded' ELSE status END
4097
+ WHERE payment_intent_id = ? AND status IN ('paid', 'refunded') RETURNING \${COLUMNS}\`,
4098
+ [charge.amountRefunded, charge.refunded ? 1 : 0, charge.paymentIntentId],
4099
+ parseOrder,
4100
+ )
4101
+ if (order) await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })
4102
+ }
4103
+
4104
+ /**
4105
+ * A chargeback. While it is open the order is \`disputed\`: hold back anything not yet
4106
+ * delivered, and answer it with evidence in the Stripe dashboard before the deadline shown
4107
+ * there. Won, the order is \`paid\` again; lost, it stays \`disputed\`. The dispute's status is
4108
+ * stored, so a \`created\` event arriving after \`closed\` cannot reopen it.
4109
+ */
4110
+ async function disputeOrder(
4111
+ env: CheckoutEnv,
4112
+ type: DisputeEventType,
4113
+ dispute: Dispute,
4114
+ ): Promise<void> {
4115
+ if (!dispute.paymentIntentId) return
4116
+ await migrate(env.DB, migrations)
4117
+ const [order] =
4118
+ type === 'charge.dispute.created'
4119
+ ? await queryRows(
4120
+ env.DB,
4121
+ \`UPDATE orders SET status = 'disputed', dispute_status = ?
4122
+ WHERE payment_intent_id = ? AND status = 'paid' AND dispute_status IS NULL
4123
+ RETURNING \${COLUMNS}\`,
4124
+ [dispute.status, dispute.paymentIntentId],
4125
+ parseOrder,
4126
+ )
4127
+ : await queryRows(
4128
+ env.DB,
4129
+ \`UPDATE orders SET dispute_status = ?,
4130
+ status = CASE WHEN ? = 'won' THEN 'paid' ELSE 'disputed' END
4131
+ WHERE payment_intent_id = ? AND status IN ('paid', 'disputed') RETURNING \${COLUMNS}\`,
4132
+ [dispute.status, dispute.status, dispute.paymentIntentId],
4133
+ parseOrder,
4134
+ )
4135
+ if (!order) return
4136
+ if (type === 'charge.dispute.created') {
4137
+ console.warn(
4138
+ \`[checkout] order \${order.id} is disputed (\${dispute.reason ?? 'no reason given'})\`,
4139
+ )
4140
+ }
4141
+ await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })
4142
+ }
4143
+
4144
+ /** What the webhook hands to worker/billing.ts, when the app bills subscriptions. */
4145
+ export interface BillingHooks {
4146
+ /** A subscription changed: store its current state. */
4147
+ subscription(subscriptionId: string): Promise<void>
4148
+ /** A renewal could not be charged: tell the customer. */
4149
+ paymentFailed(invoice: Invoice, origin: string): Promise<void>
4150
+ }
4151
+
4054
4152
  /**
4055
4153
  * Stripe's webhook: verified against STRIPE_WEBHOOK_SECRET before anything in it is read,
4056
- * then each Checkout event settles its order. Subscription events, and completed subscription
4057
- * checkouts, go to \`onSubscription\` when the app bills subscriptions (worker/billing.ts).
4058
- * Other events are acknowledged, so Stripe stops sending them.
4154
+ * then each Checkout event settles its order, and refunds and disputes update it. Subscription
4155
+ * and invoice events, and completed subscription checkouts, go to \`billing\` when the app bills
4156
+ * subscriptions (worker/billing.ts). Other events are acknowledged, so Stripe stops sending them.
4059
4157
  */
4060
4158
  export async function receiveStripe(
4061
4159
  request: Request,
4062
4160
  env: CheckoutEnv,
4063
- onSubscription?: (subscriptionId: string) => Promise<void>,
4161
+ billing?: BillingHooks,
4064
4162
  ): Promise<Response> {
4065
4163
  if (!env.STRIPE_WEBHOOK_SECRET) throw new HttpError(503, 'Set STRIPE_WEBHOOK_SECRET (README)')
4066
4164
  const { body } = await verifyWebhook(request, {
@@ -4068,12 +4166,18 @@ export async function receiveStripe(
4068
4166
  secret: env.STRIPE_WEBHOOK_SECRET,
4069
4167
  })
4070
4168
  const event = parseStripeEvent(body)
4071
- if (event.kind === 'subscription') await onSubscription?.(event.subscription.id)
4169
+ const origin = new URL(request.url).origin
4170
+ if (event.kind === 'subscription') await billing?.subscription(event.subscription.id)
4171
+ if (event.kind === 'invoice' && event.type === 'invoice.payment_failed') {
4172
+ await billing?.paymentFailed(event.invoice, origin)
4173
+ }
4174
+ if (event.kind === 'refund') await refundOrder(env, event.charge)
4175
+ if (event.kind === 'dispute') await disputeOrder(env, event.type, event.dispute)
4072
4176
  if (event.kind === 'checkout' && event.session.mode === 'subscription') {
4073
- if (event.session.subscriptionId) await onSubscription?.(event.session.subscriptionId)
4177
+ if (event.session.subscriptionId) await billing?.subscription(event.session.subscriptionId)
4074
4178
  } else if (event.kind === 'checkout') {
4075
4179
  const status = statusAfter(event.type, event.session)
4076
- if (status) await settle(env, event.session, status, new URL(request.url).origin)
4180
+ if (status) await settle(env, event.session, status, origin)
4077
4181
  }
4078
4182
  return Response.json({ received: true })
4079
4183
  }
@@ -4246,6 +4350,8 @@ const STATUS = {
4246
4350
  paid: { variant: 'success', label: 'Paid' },
4247
4351
  failed: { variant: 'destructive', label: 'Payment failed' },
4248
4352
  expired: { variant: 'secondary', label: 'Expired' },
4353
+ refunded: { variant: 'secondary', label: 'Refunded' },
4354
+ disputed: { variant: 'destructive', label: 'Disputed' },
4249
4355
  } as const
4250
4356
 
4251
4357
  /** \`/checkout/:order\` — where Stripe sends the buyer back. It updates when Stripe confirms. */
@@ -4281,11 +4387,27 @@ export default function OrderPage({ params }: RouteProps<'/checkout/:order'>) {
4281
4387
  This page updates by itself when Stripe confirms. A bank payment can take a few days.
4282
4388
  </Alert>
4283
4389
  ) : null}
4284
- {order.status === 'paid' ? (
4390
+ {order.status === 'paid' && order.refundedAmount === 0 ? (
4285
4391
  <Alert variant="success" title="Thank you">
4286
4392
  Your payment went through. A receipt is on its way to your inbox.
4287
4393
  </Alert>
4288
4394
  ) : null}
4395
+ {order.status === 'paid' && order.refundedAmount > 0 ? (
4396
+ <Alert variant="info" title="Partly refunded">
4397
+ {formatPrice(order.refundedAmount, order.currency)} of this order was refunded to the card
4398
+ that paid it.
4399
+ </Alert>
4400
+ ) : null}
4401
+ {order.status === 'refunded' ? (
4402
+ <Alert variant="info" title="Refunded">
4403
+ The full amount went back to the card that paid it. It can take a few days to show.
4404
+ </Alert>
4405
+ ) : null}
4406
+ {order.status === 'disputed' ? (
4407
+ <Alert variant="warning" title="This payment is disputed">
4408
+ The card's bank is reviewing a chargeback. The order is on hold until it is decided.
4409
+ </Alert>
4410
+ ) : null}
4289
4411
  {order.status === 'failed' ? (
4290
4412
  <Alert variant="destructive" title="The payment failed">
4291
4413
  Nothing was charged. <Link href="/checkout">Try again</Link>
@@ -4535,6 +4657,8 @@ import type { Database } from '@cascivo/app/db'
4535
4657
  import { SesError, createSes, handleSns, parseSesNotification } from '@cascivo/app/ses'
4536
4658
  import { writeRoom } from '@cascivo/app/sync-server'
4537
4659
  import type { RoomNamespace } from '@cascivo/app/sync-server'
4660
+ import { assertSendable, sendEmail } from '@cascivo/email'
4661
+ import type { RenderResult } from '@cascivo/email'
4538
4662
  import { ISSUE_ID, issueRoom, parseIssue } from '../src/newsletter'
4539
4663
  import type { Issue, IssueInput, Overview, SubscriberCounts } from '../src/newsletter'
4540
4664
  import { TOKEN_SLOT, renderConfirmation, renderIssue } from './newsletter-email'
@@ -4646,18 +4770,16 @@ async function requireKey(env: NewsletterEnv, key: string): Promise<void> {
4646
4770
  }
4647
4771
  }
4648
4772
 
4649
- interface Mail {
4650
- subject: string
4651
- html: string
4652
- text: string
4653
- headers?: Record<string, string>
4654
- }
4655
-
4656
- type Send = (to: string, mail: Mail) => Promise<'sent' | 'logged'>
4773
+ type Send = (
4774
+ to: string,
4775
+ message: RenderResult,
4776
+ headers?: Record<string, string>,
4777
+ ) => Promise<'sent' | 'logged'>
4657
4778
 
4658
4779
  /**
4659
4780
  * Sends one email through SES, or, in \`vite dev\` without AWS credentials, logs it instead.
4660
- * Deployed without them, it refuses with what to set.
4781
+ * Deployed without them, it refuses with what to set. The SES client is an \`EmailSender\`, so
4782
+ * \`sendEmail\` checks the message (subject, preheader, text part, size) and every header first.
4661
4783
  */
4662
4784
  function mailer(env: NewsletterEnv): Send {
4663
4785
  const configured =
@@ -4679,8 +4801,12 @@ function mailer(env: NewsletterEnv): Send {
4679
4801
  accessKeyId: env.AWS_ACCESS_KEY_ID!,
4680
4802
  secretAccessKey: env.AWS_SECRET_ACCESS_KEY!,
4681
4803
  })
4682
- return async (to, message) => {
4683
- await ses.sendEmail({ from: env.NEWSLETTER_FROM, to, ...message })
4804
+ return async (to, message, headers) => {
4805
+ await sendEmail(ses, message, {
4806
+ from: env.NEWSLETTER_FROM,
4807
+ to,
4808
+ ...(headers ? { headers } : {}),
4809
+ })
4684
4810
  return 'sent'
4685
4811
  }
4686
4812
  }
@@ -4722,11 +4848,7 @@ export async function subscribe(
4722
4848
  const message = renderConfirmation(link)
4723
4849
  let outcome: 'sent' | 'logged'
4724
4850
  try {
4725
- outcome = await send(email, {
4726
- subject: message.subject,
4727
- html: message.html,
4728
- text: message.text,
4729
- })
4851
+ outcome = await send(email, message)
4730
4852
  } catch (error) {
4731
4853
  if (!(error instanceof SesError)) throw error
4732
4854
  console.error('[newsletter] confirmation not sent:', error.code, error.message)
@@ -4840,6 +4962,12 @@ export async function sendIssue(
4840
4962
  ): Promise<Issue> {
4841
4963
  await requireKey(env, input.key)
4842
4964
  mailer(env) // Deployed without SES, refuse now rather than fail in the queue.
4965
+ try {
4966
+ // An issue too large to send (Gmail clips it) is refused here, not retried in the queue.
4967
+ assertSendable(renderIssue(input.subject, input.body, origin))
4968
+ } catch (error) {
4969
+ throw new HttpError(400, error instanceof Error ? error.message : String(error))
4970
+ }
4843
4971
  await migrate(env.DB, migrations)
4844
4972
  const readers = await queryRows(
4845
4973
  env.DB,
@@ -4917,15 +5045,18 @@ export async function deliver(env: NewsletterEnv, batch: NewsletterBatch): Promi
4917
5045
  let status: string
4918
5046
  let detail: string | null = null
4919
5047
  try {
4920
- status = await send(email, {
4921
- subject: rendered.subject,
4922
- html: rendered.html.replaceAll(TOKEN_SLOT, readerToken),
4923
- text: rendered.text.replaceAll(TOKEN_SLOT, readerToken),
4924
- headers: {
5048
+ status = await send(
5049
+ email,
5050
+ {
5051
+ ...rendered,
5052
+ html: rendered.html.replaceAll(TOKEN_SLOT, readerToken),
5053
+ text: rendered.text.replaceAll(TOKEN_SLOT, readerToken),
5054
+ },
5055
+ {
4925
5056
  'List-Unsubscribe': \`<\${unsubscribe}>\`,
4926
5057
  'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
4927
5058
  },
4928
- })
5059
+ )
4929
5060
  } catch (error) {
4930
5061
  if (!(error instanceof SesError) || error.retryable) throw error
4931
5062
  status = 'failed'
@@ -5401,7 +5532,10 @@ const STATUSES: readonly BillingStatus[] = [
5401
5532
 
5402
5533
  export interface Billing {
5403
5534
  status: BillingStatus
5404
- /** Whether the plan's features are on: the subscription is active or trialing. */
5535
+ /**
5536
+ * Whether the plan's features are on: the subscription is active or trialing, or past due
5537
+ * while Stripe retries the renewal (\`isEntitled\` from @cascivo/app/stripe, in the Worker).
5538
+ */
5405
5539
  active: boolean
5406
5540
  /** When the current period ends: the next charge, or the end of a cancelled plan. */
5407
5541
  currentPeriodEnd: string | null
@@ -5411,9 +5545,6 @@ export interface Billing {
5411
5545
  canManage: boolean
5412
5546
  }
5413
5547
 
5414
- /** The statuses that unlock the plan. Check this in the Worker before serving a paid feature. */
5415
- export const isActive = (status: BillingStatus) => status === 'active' || status === 'trialing'
5416
-
5417
5548
  export function parseBilling(raw: unknown): Billing {
5418
5549
  if (typeof raw === 'object' && raw !== null) {
5419
5550
  const { status, active, currentPeriodEnd, cancelAtPeriodEnd, canManage } = raw as Record<
@@ -5454,10 +5585,26 @@ export function parseSyncInput(raw: unknown): { sessionId: string } {
5454
5585
  import { requireUser } from '@cascivo/app/auth-server'
5455
5586
  import { migrate, queryRows } from '@cascivo/app/db'
5456
5587
  import type { Database } from '@cascivo/app/db'
5457
- import type { Subscription } from '@cascivo/app/stripe'
5458
- import { PLAN, isActive, parseBilling } from '../src/billing'
5588
+ import { isEntitled, requireEntitlement } from '@cascivo/app/stripe'
5589
+ import type { Invoice, Subscription } from '@cascivo/app/stripe'
5590
+ import {
5591
+ Body,
5592
+ Button,
5593
+ Container,
5594
+ Head,
5595
+ Heading,
5596
+ Html,
5597
+ Preview,
5598
+ Section,
5599
+ Text,
5600
+ renderEmail,
5601
+ } from '@cascivo/email'
5602
+ import { createElement as h } from 'react'
5603
+ import { PLAN, parseBilling } from '../src/billing'
5459
5604
  import type { Billing } from '../src/billing'
5605
+ import { SHOP_NAME, formatPrice } from '../src/checkout'
5460
5606
  import { refused, stripeOf } from './checkout'
5607
+ import type { BillingHooks, ReceiptSender } from './checkout'
5461
5608
 
5462
5609
  const migrations = [
5463
5610
  {
@@ -5470,7 +5617,8 @@ const migrations = [
5470
5617
  status TEXT NOT NULL,
5471
5618
  current_period_end INTEGER,
5472
5619
  cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
5473
- updated_at TEXT NOT NULL
5620
+ updated_at TEXT NOT NULL,
5621
+ reminded TEXT
5474
5622
  )\`,
5475
5623
  ],
5476
5624
  },
@@ -5478,6 +5626,8 @@ const migrations = [
5478
5626
 
5479
5627
  export interface BillingEnv {
5480
5628
  DB: Database
5629
+ EMAIL: ReceiptSender
5630
+ RECEIPT_FROM: string
5481
5631
  STRIPE_SECRET_KEY?: string
5482
5632
  }
5483
5633
 
@@ -5526,7 +5676,7 @@ function toBilling(row: Row | null): Billing {
5526
5676
  cancelAtPeriodEnd: row?.cancelAtPeriodEnd === 1,
5527
5677
  canManage: row?.customerId != null,
5528
5678
  })
5529
- return { ...billing, active: isActive(billing.status) }
5679
+ return { ...billing, active: isEntitled(billing.status) }
5530
5680
  }
5531
5681
 
5532
5682
  /**
@@ -5578,6 +5728,102 @@ export async function getBilling(env: BillingEnv, request: Request): Promise<Bil
5578
5728
  return toBilling(await readRow(env.DB, user.id))
5579
5729
  }
5580
5730
 
5731
+ /**
5732
+ * Refuses a user whose plan is not on: 401 signed out, 402 without the plan. Call it in the
5733
+ * Worker before serving a paid feature, never trusting what the page shows.
5734
+ */
5735
+ export async function requirePlan(env: BillingEnv, request: Request): Promise<void> {
5736
+ const user = await requireUser(env.DB, request)
5737
+ requireEntitlement((await readRow(env.DB, user.id))?.status)
5738
+ }
5739
+
5740
+ /** The email for a renewal that could not be charged, with where to pay it. */
5741
+ function renderPaymentFailed(invoice: Invoice, payHref: string) {
5742
+ const subject = \`Your \${PLAN.name} payment did not go through\`
5743
+ const amount = formatPrice(invoice.amountDue, invoice.currency)
5744
+ return renderEmail(
5745
+ h(
5746
+ Html,
5747
+ null,
5748
+ h(Head, { title: subject }),
5749
+ h(
5750
+ Body,
5751
+ null,
5752
+ h(Preview, null, \`We could not charge \${amount}. Update your card to keep \${PLAN.name}.\`),
5753
+ h(
5754
+ Container,
5755
+ null,
5756
+ h(
5757
+ Section,
5758
+ { padding: 32 },
5759
+ h(Heading, { level: 1 }, 'Your payment did not go through'),
5760
+ h(
5761
+ Text,
5762
+ null,
5763
+ \`We could not charge \${amount} for \${SHOP_NAME} \${PLAN.name}. Your plan stays on while we try again; pay with another card to keep it.\`,
5764
+ ),
5765
+ h(Button, { href: payHref }, 'Update payment'),
5766
+ ),
5767
+ ),
5768
+ ),
5769
+ ),
5770
+ { subject },
5771
+ )
5772
+ }
5773
+
5774
+ /**
5775
+ * A renewal Stripe could not charge (\`invoice.payment_failed\`). The customer hears about it
5776
+ * once per attempt, with Stripe's page for paying the invoice with another card. The plan stays
5777
+ * on while Stripe retries (\`past_due\`); the Stripe dashboard's failed-payment settings decide
5778
+ * when it ends. Only customers this app bills are written to: a retried event, or an invoice
5779
+ * for something else on the same Stripe account, sends nothing.
5780
+ */
5781
+ export async function remindPayment(
5782
+ env: BillingEnv,
5783
+ invoice: Invoice,
5784
+ origin: string,
5785
+ ): Promise<void> {
5786
+ if (!invoice.customerId || !invoice.customerEmail) return
5787
+ await migrate(env.DB, migrations)
5788
+ const attempt = \`\${invoice.id}:\${invoice.attemptCount}\`
5789
+ const [row] = await queryRows(
5790
+ env.DB,
5791
+ 'UPDATE billing SET reminded = ? WHERE customer_id = ? AND reminded IS NOT ? RETURNING user_id',
5792
+ [attempt, invoice.customerId, attempt],
5793
+ (raw) => raw,
5794
+ )
5795
+ if (!row) return
5796
+ const message = renderPaymentFailed(invoice, invoice.hostedInvoiceUrl ?? \`\${origin}/billing\`)
5797
+ if (import.meta.env.DEV) {
5798
+ console.log(\`[billing] payment reminder for \${invoice.customerEmail}: "\${message.subject}"\`)
5799
+ return
5800
+ }
5801
+ if (!env.RECEIPT_FROM) {
5802
+ console.warn('[billing] no payment reminder sent: set RECEIPT_FROM in wrangler.jsonc')
5803
+ return
5804
+ }
5805
+ try {
5806
+ await env.EMAIL.send({
5807
+ from: env.RECEIPT_FROM,
5808
+ to: invoice.customerEmail,
5809
+ subject: message.subject,
5810
+ text: message.text,
5811
+ html: message.html,
5812
+ })
5813
+ } catch (error) {
5814
+ // Logged, not thrown: Stripe retrying the event would not send it again.
5815
+ console.error('[billing] payment reminder not sent:', error)
5816
+ }
5817
+ }
5818
+
5819
+ /** What the Stripe webhook (worker/checkout.ts) hands over to billing. */
5820
+ export function billingHooks(env: BillingEnv): BillingHooks {
5821
+ return {
5822
+ subscription: (id) => syncSubscription(env, id),
5823
+ paymentFailed: (invoice, origin) => remindPayment(env, invoice, origin),
5824
+ }
5825
+ }
5826
+
5581
5827
  /** Opens a subscription checkout for PLAN, naming the user in the subscription's metadata. */
5582
5828
  export async function startSubscription(
5583
5829
  env: BillingEnv,
@@ -7546,8 +7792,9 @@ it to the order page, and emails a receipt rendered with \`@cascivo/email\`.
7546
7792
  3. Deployed: \`npx wrangler secret put STRIPE_SECRET_KEY\` and
7547
7793
  \`npx wrangler secret put STRIPE_WEBHOOK_SECRET\`. In the dashboard, add a webhook endpoint
7548
7794
  at \`https://<your app>/api/stripe/webhook\` for \`checkout.session.completed\`,
7549
- \`checkout.session.async_payment_succeeded\`, \`checkout.session.async_payment_failed\` and
7550
- \`checkout.session.expired\`; its signing secret is \`STRIPE_WEBHOOK_SECRET\`.
7795
+ \`checkout.session.async_payment_succeeded\`, \`checkout.session.async_payment_failed\`,
7796
+ \`checkout.session.expired\`, \`charge.refunded\`, \`charge.dispute.created\` and
7797
+ \`charge.dispute.closed\`; its signing secret is \`STRIPE_WEBHOOK_SECRET\`.
7551
7798
  4. Receipts: set \`RECEIPT_FROM\` in \`wrangler.jsonc\` to an address on a domain you have
7552
7799
  onboarded to Email Service. \`vite dev\` renders each receipt and logs it instead.
7553
7800
 
@@ -7563,10 +7810,38 @@ browser cannot change it.
7563
7810
  set on a Payment Link.
7564
7811
  - A bank debit completes the session as \`unpaid\`: the order stays pending until
7565
7812
  \`async_payment_succeeded\` or \`async_payment_failed\` arrives, possibly days later.
7813
+ - Refunds are made in the Stripe dashboard (or with \`createRefund\`); \`charge.refunded\` records
7814
+ the amount, and an order refunded in full becomes \`refunded\`. A chargeback makes it
7815
+ \`disputed\` until it is decided: answer it with evidence in the dashboard. Both find the
7816
+ order by the payment it stored when it was paid.
7566
7817
  - \`src/routes/checkout/[order].tsx\` — where Stripe sends the buyer back. It watches the
7567
7818
  order's read-only room, so it updates when the webhook arrives.
7568
7819
 
7569
- Each caller (by IP) may start 20 checkouts a minute.${J(e)?"\n\n### Subscriptions (`/billing`)\n\nSigned-in users subscribe to `PLAN` (`src/billing.ts`) on Stripe's checkout and change,\npause or cancel it in Stripe's hosted billing portal, so the app has no billing screens to build.\n\n1. Add `customer.subscription.created`, `customer.subscription.updated` and\n `customer.subscription.deleted` to the webhook endpoint's events.\n2. In the Stripe dashboard, save the Customer Portal's settings once (test mode too): until\n then, Stripe refuses to open it.\n3. Gate a paid feature in the Worker on `(await billingStore.getBilling(env, request)).active`,\n never on what the page shows.\n\n- `worker/billing.ts` — the subscription names its user in its metadata, which only the\n Worker sets (`client_reference_id` can be set by a buyer on a Payment Link). Every\n subscription event is read back from Stripe before it is stored, because events arrive out of\n order, and a late event about an older subscription cannot end a live one.\n- Back from checkout, `/billing` reads the session and its subscription at once, so it is right\n before the webhook arrives, and only for the user the subscription names.":`
7820
+ Each caller (by IP) may start 20 checkouts a minute.${J(e)?`
7821
+
7822
+ ### Subscriptions (\`/billing\`)
7823
+
7824
+ Signed-in users subscribe to \`PLAN\` (\`src/billing.ts\`) on Stripe's checkout and change,
7825
+ pause or cancel it in Stripe's hosted billing portal, so the app has no billing screens to build.
7826
+
7827
+ 1. Add \`customer.subscription.created\`, \`customer.subscription.updated\`,
7828
+ \`customer.subscription.deleted\` and \`invoice.payment_failed\` to the webhook endpoint's
7829
+ events.
7830
+ 2. In the Stripe dashboard, save the Customer Portal's settings once (test mode too): until
7831
+ then, Stripe refuses to open it.
7832
+ 3. Gate a paid feature in the Worker with \`await billingStore.requirePlan(env, request)\` (402
7833
+ without the plan), never on what the page shows.
7834
+
7835
+ - \`worker/billing.ts\` — the subscription names its user in its metadata, which only the
7836
+ Worker sets (\`client_reference_id\` can be set by a buyer on a Payment Link). Every
7837
+ subscription event is read back from Stripe before it is stored, because events arrive out of
7838
+ order, and a late event about an older subscription cannot end a live one.
7839
+ - Back from checkout, \`/billing\` reads the session and its subscription at once, so it is right
7840
+ before the webhook arrives, and only for the user the subscription names.
7841
+ - A renewal that cannot be charged leaves the plan on (\`past_due\`) while Stripe retries, and
7842
+ emails the customer once per attempt with Stripe's page to pay with another card. How long
7843
+ Stripe retries, and whether it then cancels, is set in the dashboard (Billing → Subscriptions
7844
+ and emails); turn off Stripe's own failed-payment emails there, or customers get two.`:`
7570
7845
 
7571
7846
  Subscriptions need an account to belong to: \`cascivo create --framework cloudflare
7572
7847
  --example checkout --auth email\` adds a \`/billing\` page with a monthly plan and Stripe's
@@ -7597,7 +7872,8 @@ To send for real:
7597
7872
  - \`worker/newsletter.ts\` — double opt-in: a sign-up gets a confirmation link (valid a day,
7598
7873
  resent at most every ten minutes) and gets no issue until it is opened. Sending stores the
7599
7874
  issue and puts its readers on the \`NEWSLETTER\` queue, 25 per message; the consumer sends
7600
- them one by one through \`createSes\` (\`@cascivo/app/ses\`), one message at a time. Each
7875
+ them one by one through \`createSes\` (\`@cascivo/app/ses\`), handed to \`sendEmail\` from
7876
+ \`@cascivo/email\`, which checks each message before it leaves, one message at a time. Each
7601
7877
  send is recorded, so a message retried after SES throttling skips whoever already has it.
7602
7878
  - Every issue carries \`List-Unsubscribe\` and \`List-Unsubscribe-Post\` (one-click
7603
7879
  unsubscribe, RFC 8058, which Gmail and Yahoo require of bulk senders) and a footer link to
@@ -7711,4 +7987,4 @@ Good to know:`),console.log(` No cascivo.config.ts is written — this app uses
7711
7987
  No account yet? Share a 60-minute preview, claimable into a free account:`),console.log(` ${w(M,`deploy:preview`)}`)):m===`astro`?(console.log(`
7712
7988
  Pages are real Astro routes — no client router to add. Only src/`),console.log(` components/Shell.tsx hydrates (client:load, for the mobile nav drawer);`),console.log(` page content is server-rendered and ships no JS. See`),console.log(` https://cascivo.com/docs/using-with-astro.md`)):(console.log(`
7713
7989
  Adding a router? Keep src/Shell.tsx, delete src/App.tsx + src/sections/,`),console.log(` and register your Link once with setLinkComponent — see`),console.log(` https://cascivo.com/docs/using-with-a-router.md`))}finally{N?.close()}}export{hn as create};
7714
- //# sourceMappingURL=create-xLiwiyNv.mjs.map
7990
+ //# sourceMappingURL=create-DmBKREqv.mjs.map