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.
|
|
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?`, (
|
|
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
|
-
/**
|
|
3797
|
-
|
|
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<
|
|
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
|
-
|
|
3837
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
4057
|
-
* checkouts, go to \`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
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
|
|
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(
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
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
|
-
/**
|
|
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
|
|
5458
|
-
import {
|
|
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:
|
|
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
|
|
7550
|
-
\`checkout.session.expired
|
|
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)
|
|
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\`),
|
|
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-
|
|
7990
|
+
//# sourceMappingURL=create-DmBKREqv.mjs.map
|