From 83f53ac3f605f4ef0ba6bf44f4e8d58999e7e767 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Thu, 1 Jun 2023 19:05:11 +0200 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=9A=9A=20Rename=20subscription=20to?= =?UTF-8?q?=20"bootik=20subscriptions"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/database.ts | 8 ++++---- src/lib/server/handle-messages.ts | 4 ++-- src/lib/types/{Subscription.ts => BootikSubscription.ts} | 2 +- src/routes/admin/product/new/+page.server.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/lib/types/{Subscription.ts => BootikSubscription.ts} (68%) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index e03ae33da..fa5b6a3ab 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -9,7 +9,7 @@ import type { DigitalFile } from '$lib/types/DigitalFile'; import type { Order } from '$lib/types/Order'; import type { NostRNotification } from '$lib/types/NostRNotifications'; import type { NostRReceivedMessage } from '$lib/types/NostRReceivedMessage'; -import type { Subscription } from '$lib/types/Subscription'; +import type { BootikSubscription } from '$lib/types/BootikSubscription'; const client = new MongoClient(MONGODB_URL, { // directConnection: true @@ -22,7 +22,7 @@ const db = client.db(MONGODB_DB); // const users = db.collection('users'); const pictures = db.collection('pictures'); const products = db.collection('products'); -const subscriptions = db.collection('subscriptions'); +const bootikSubscriptions = db.collection('subscriptions'); const carts = db.collection('carts'); const runtimeConfig = db.collection('runtimeConfig'); const locks = db.collection('locks'); @@ -47,7 +47,7 @@ export const collections = { orders, nostrNotifications, nostrReceivedMessages, - subscriptions + bootikSubscriptions }; export function transaction(dbTransactions: WithSessionCallback): Promise { @@ -67,7 +67,7 @@ client.on('open', () => { digitalFiles.createIndex({ productId: 1 }); nostrReceivedMessages.createIndex({ createdAt: -1 }); nostrNotifications.createIndex({ dest: 1 }); - subscriptions.createIndex({ npub: 1 }, { sparse: true }); + bootikSubscriptions.createIndex({ npub: 1 }, { sparse: true }); }); export async function withTransaction(cb: WithSessionCallback) { diff --git a/src/lib/server/handle-messages.ts b/src/lib/server/handle-messages.ts index 4891f5c87..f747d8e83 100644 --- a/src/lib/server/handle-messages.ts +++ b/src/lib/server/handle-messages.ts @@ -100,7 +100,7 @@ async function handleChanges(change: ChangeStreamDocument) if (!runtimeConfig.discovery) { await send('Discovery is not enabled for the bootik, you cannot subscribe'); } else { - await collections.subscriptions.updateOne( + await collections.bootikSubscriptions.updateOne( { npub: senderNpub }, { $set: { @@ -118,7 +118,7 @@ async function handleChanges(change: ChangeStreamDocument) } break; case 'unsubscribe': { - const result = await collections.subscriptions.deleteOne({ npub: senderNpub }); + const result = await collections.bootikSubscriptions.deleteOne({ npub: senderNpub }); if (result.deletedCount) { await send('You were unsubscribed from the catalog'); diff --git a/src/lib/types/Subscription.ts b/src/lib/types/BootikSubscription.ts similarity index 68% rename from src/lib/types/Subscription.ts rename to src/lib/types/BootikSubscription.ts index 02a466b96..39dd0ca72 100644 --- a/src/lib/types/Subscription.ts +++ b/src/lib/types/BootikSubscription.ts @@ -1,7 +1,7 @@ import type { ObjectId } from 'mongodb'; import type { Timestamps } from './Timestamps'; -export interface Subscription extends Timestamps { +export interface BootikSubscription extends Timestamps { _id: ObjectId; npub: string; diff --git a/src/routes/admin/product/new/+page.server.ts b/src/routes/admin/product/new/+page.server.ts index b413ec15d..e95624d32 100644 --- a/src/routes/admin/product/new/+page.server.ts +++ b/src/routes/admin/product/new/+page.server.ts @@ -122,7 +122,7 @@ export const actions: Actions = { // a change stream would probably be better if (runtimeConfig.discovery) { (async function () { - for await (const subscription of collections.subscriptions.find({ + for await (const subscription of collections.bootikSubscriptions.find({ npub: { $exists: true } })) { await collections.nostrNotifications From ec0e9af717500aa43743c10da1b57fffe151395a Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Thu, 1 Jun 2023 19:13:06 +0200 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=9A=A7=20Prepare=20paid=20subscript?= =?UTF-8?q?ion=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/database.ts | 9 ++++++++- src/lib/types/BootikSubscription.ts | 3 +++ src/lib/types/Order.ts | 2 ++ src/lib/types/PaidSubscription.ts | 16 ++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/lib/types/PaidSubscription.ts diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index fa5b6a3ab..e79e7d326 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -10,6 +10,7 @@ import type { Order } from '$lib/types/Order'; import type { NostRNotification } from '$lib/types/NostRNotifications'; import type { NostRReceivedMessage } from '$lib/types/NostRReceivedMessage'; import type { BootikSubscription } from '$lib/types/BootikSubscription'; +import type { PaidSubscription } from '$lib/types/PaidSubscription'; const client = new MongoClient(MONGODB_URL, { // directConnection: true @@ -23,6 +24,7 @@ const db = client.db(MONGODB_DB); const pictures = db.collection('pictures'); const products = db.collection('products'); const bootikSubscriptions = db.collection('subscriptions'); +const paidSubscriptions = db.collection('subscriptions.paid'); const carts = db.collection('carts'); const runtimeConfig = db.collection('runtimeConfig'); const locks = db.collection('locks'); @@ -47,7 +49,8 @@ export const collections = { orders, nostrNotifications, nostrReceivedMessages, - bootikSubscriptions + bootikSubscriptions, + paidSubscriptions }; export function transaction(dbTransactions: WithSessionCallback): Promise { @@ -68,6 +71,10 @@ client.on('open', () => { nostrReceivedMessages.createIndex({ createdAt: -1 }); nostrNotifications.createIndex({ dest: 1 }); bootikSubscriptions.createIndex({ npub: 1 }, { sparse: true }); + paidSubscriptions.createIndex( + { npub: 1, productId: 1 }, + { unique: true, partialFilterExpression: { npub: { $exists: true } } } + ); }); export async function withTransaction(cb: WithSessionCallback) { diff --git a/src/lib/types/BootikSubscription.ts b/src/lib/types/BootikSubscription.ts index 39dd0ca72..c48f8fbcb 100644 --- a/src/lib/types/BootikSubscription.ts +++ b/src/lib/types/BootikSubscription.ts @@ -1,3 +1,6 @@ +/** + * For subscriptions to the catalog + */ import type { ObjectId } from 'mongodb'; import type { Timestamps } from './Timestamps'; diff --git a/src/lib/types/Order.ts b/src/lib/types/Order.ts index d672b4cae..cb6caa73e 100644 --- a/src/lib/types/Order.ts +++ b/src/lib/types/Order.ts @@ -2,6 +2,7 @@ import type { Product } from './Product'; import type { Currency } from './Currency'; import type { CountryAlpha3 } from './Country'; import type { Timestamps } from './Timestamps'; +import type { ObjectId } from 'mongodb'; export interface Order extends Timestamps { /** @@ -15,6 +16,7 @@ export interface Order extends Timestamps { items: Array<{ product: Product; quantity: number; + subscriptionId?: ObjectId; }>; shippingAddress?: { diff --git a/src/lib/types/PaidSubscription.ts b/src/lib/types/PaidSubscription.ts new file mode 100644 index 000000000..0e6abcb3c --- /dev/null +++ b/src/lib/types/PaidSubscription.ts @@ -0,0 +1,16 @@ +/** + * For paid subscriptions + */ + +import type { ObjectId } from 'mongodb'; +import type { Timestamps } from './Timestamps'; + +export interface PaidSubscription extends Timestamps { + _id: ObjectId; + + npub: string; + productId: string; + + lastPaidAt?: Date; + lastNotifiedAt?: Date; +} From dd6ace47860b83ec0d89553c6b11e6938f68670a Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Thu, 1 Jun 2023 23:04:55 +0200 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=94=A7=20Add=20subscription=20durat?= =?UTF-8?q?ion=20/=20reminder=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/runtime-config.ts | 2 ++ src/lib/types/PaidSubscription.ts | 4 ++-- src/routes/admin/config/+page.server.ts | 26 +++++++++++++++++++++-- src/routes/admin/config/+page.svelte | 28 +++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/lib/server/runtime-config.ts b/src/lib/server/runtime-config.ts index e95ae70e8..a1eb3bdec 100644 --- a/src/lib/server/runtime-config.ts +++ b/src/lib/server/runtime-config.ts @@ -4,6 +4,8 @@ import { collections } from './database'; const defaultConfig = { BTC_EUR: 30_000, orderNumber: 0, + subscriptionDuration: "month" as "month" | "day" | "hour", + subscriptionReminderSeconds: 24 * 60 * 60, checkoutButtonOnProductPage: true, discovery: true diff --git a/src/lib/types/PaidSubscription.ts b/src/lib/types/PaidSubscription.ts index 0e6abcb3c..a6ab58c72 100644 --- a/src/lib/types/PaidSubscription.ts +++ b/src/lib/types/PaidSubscription.ts @@ -11,6 +11,6 @@ export interface PaidSubscription extends Timestamps { npub: string; productId: string; - lastPaidAt?: Date; - lastNotifiedAt?: Date; + paidUntil?: Date; + lastRemindedAt?: Date; } diff --git a/src/routes/admin/config/+page.server.ts b/src/routes/admin/config/+page.server.ts index c408deeb2..d9270b05b 100644 --- a/src/routes/admin/config/+page.server.ts +++ b/src/routes/admin/config/+page.server.ts @@ -7,6 +7,8 @@ export async function load() { return { checkoutButtonOnProductPage: runtimeConfig.checkoutButtonOnProductPage, discovery: runtimeConfig.discovery, + subscriptionDuration: runtimeConfig.subscriptionDuration, + subscriptionReminderSeconds: runtimeConfig.subscriptionReminderSeconds, origin: ORIGIN }; } @@ -18,11 +20,15 @@ export const actions = { const result = z .object({ checkoutButtonOnProductPage: z.boolean({ coerce: true }), - discovery: z.boolean({ coerce: true }) + discovery: z.boolean({ coerce: true }), + subscriptionDuration: z.enum(["month", "day", "hour"]), + subscriptionReminderSeconds: z.number({coerce: true}).int().min(0).max(24 * 60 * 60 * 7) }) .parse({ checkoutButtonOnProductPage: formData.get('checkoutButtonOnProductPage'), - discovery: formData.get('discovery') + discovery: formData.get('discovery'), + subscriptionDuration: formData.get('subscriptionDuration'), + subscriptionReminderSeconds: formData.get('subscriptionReminderSeconds') }); if (runtimeConfig.checkoutButtonOnProductPage !== result.checkoutButtonOnProductPage) { @@ -41,5 +47,21 @@ export const actions = { { upsert: true } ); } + if (runtimeConfig.subscriptionDuration !== result.subscriptionDuration) { + runtimeConfig.subscriptionDuration = result.subscriptionDuration; + await collections.runtimeConfig.updateOne( + { _id: 'subscriptionDuration' }, + { $set: { data: result.subscriptionDuration, updatedAt: new Date() } }, + { upsert: true } + ); + } + if (runtimeConfig.subscriptionReminderSeconds !== result.subscriptionReminderSeconds) { + runtimeConfig.subscriptionReminderSeconds = result.subscriptionReminderSeconds; + await collections.runtimeConfig.updateOne( + { _id: 'subscriptionReminderSeconds' }, + { $set: { data: result.subscriptionReminderSeconds, updatedAt: new Date() } }, + { upsert: true } + ); + } } }; diff --git a/src/routes/admin/config/+page.svelte b/src/routes/admin/config/+page.svelte index e7f34badd..7260c76f8 100644 --- a/src/routes/admin/config/+page.svelte +++ b/src/routes/admin/config/+page.svelte @@ -1,4 +1,6 @@ @@ -25,5 +27,31 @@ /> discovery + + From e3528f7471dccfbc4e0dd97af791eb302c1258e4 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Thu, 1 Jun 2023 23:35:18 +0200 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=A6=BA=20Prevent=20subscribing=20tw?= =?UTF-8?q?ice=20to=20same=20thing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 -- src/lib/server/runtime-config.ts | 2 +- src/lib/types/Order.ts | 2 -- src/lib/types/PaidSubscription.ts | 2 +- src/routes/admin/config/+page.server.ts | 8 +++-- src/routes/checkout/+page.server.ts | 46 ++++++++++++++++++++++++- 6 files changed, 53 insertions(+), 10 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index f9dcb5d96..4180dd73a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,8 +9,5 @@ }, "[css]": { "editor.defaultFormatter": "vscode.css-language-features" - }, - "[typescript]": { - "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" } } diff --git a/src/lib/server/runtime-config.ts b/src/lib/server/runtime-config.ts index a1eb3bdec..73e34d52f 100644 --- a/src/lib/server/runtime-config.ts +++ b/src/lib/server/runtime-config.ts @@ -4,7 +4,7 @@ import { collections } from './database'; const defaultConfig = { BTC_EUR: 30_000, orderNumber: 0, - subscriptionDuration: "month" as "month" | "day" | "hour", + subscriptionDuration: 'month' as 'month' | 'day' | 'hour', subscriptionReminderSeconds: 24 * 60 * 60, checkoutButtonOnProductPage: true, diff --git a/src/lib/types/Order.ts b/src/lib/types/Order.ts index cb6caa73e..d672b4cae 100644 --- a/src/lib/types/Order.ts +++ b/src/lib/types/Order.ts @@ -2,7 +2,6 @@ import type { Product } from './Product'; import type { Currency } from './Currency'; import type { CountryAlpha3 } from './Country'; import type { Timestamps } from './Timestamps'; -import type { ObjectId } from 'mongodb'; export interface Order extends Timestamps { /** @@ -16,7 +15,6 @@ export interface Order extends Timestamps { items: Array<{ product: Product; quantity: number; - subscriptionId?: ObjectId; }>; shippingAddress?: { diff --git a/src/lib/types/PaidSubscription.ts b/src/lib/types/PaidSubscription.ts index a6ab58c72..e7de5a752 100644 --- a/src/lib/types/PaidSubscription.ts +++ b/src/lib/types/PaidSubscription.ts @@ -11,6 +11,6 @@ export interface PaidSubscription extends Timestamps { npub: string; productId: string; - paidUntil?: Date; + paidUntil: Date; lastRemindedAt?: Date; } diff --git a/src/routes/admin/config/+page.server.ts b/src/routes/admin/config/+page.server.ts index d9270b05b..b21a550e2 100644 --- a/src/routes/admin/config/+page.server.ts +++ b/src/routes/admin/config/+page.server.ts @@ -21,8 +21,12 @@ export const actions = { .object({ checkoutButtonOnProductPage: z.boolean({ coerce: true }), discovery: z.boolean({ coerce: true }), - subscriptionDuration: z.enum(["month", "day", "hour"]), - subscriptionReminderSeconds: z.number({coerce: true}).int().min(0).max(24 * 60 * 60 * 7) + subscriptionDuration: z.enum(['month', 'day', 'hour']), + subscriptionReminderSeconds: z + .number({ coerce: true }) + .int() + .min(0) + .max(24 * 60 * 60 * 7) }) .parse({ checkoutButtonOnProductPage: formData.get('checkoutButtonOnProductPage'), diff --git a/src/routes/checkout/+page.server.ts b/src/routes/checkout/+page.server.ts index 4112972dd..10bba3156 100644 --- a/src/routes/checkout/+page.server.ts +++ b/src/routes/checkout/+page.server.ts @@ -4,11 +4,12 @@ import { lndCreateInvoice } from '$lib/server/lightning.js'; import { paymentMethods } from '$lib/server/payment-methods.js'; import { COUNTRY_ALPHA3S } from '$lib/types/Country'; import { error, redirect } from '@sveltejs/kit'; -import { addHours, differenceInSeconds } from 'date-fns'; +import { addHours, differenceInSeconds, subSeconds } from 'date-fns'; import { z } from 'zod'; import { bech32 } from 'bech32'; import { ORIGIN } from '$env/static/private'; import { toSatoshis } from '$lib/utils/toSatoshis.js'; +import { runtimeConfig } from '$lib/server/runtime-config.js'; export function load() { return { @@ -104,6 +105,49 @@ export const actions = { const orderId = crypto.randomUUID(); + const subscriptions = cart.items.filter((item) => byId[item.productId].type === 'subscription'); + + for (const subscription of subscriptions) { + const product = byId[subscription.productId]; + + if (subscription.quantity > 1) { + throw error( + 400, + 'Cannot order more than one of a subscription at a time for product: ' + product.name + ); + } + + const existingSubscription = await collections.paidSubscriptions.findOne({ + npub: npubAddress, + productId: product._id + }); + + if (existingSubscription) { + if ( + subSeconds(existingSubscription.paidUntil, runtimeConfig.subscriptionReminderSeconds) > + new Date() + ) { + throw error( + 400, + 'You already have an active subscription for this product: ' + product.name + ); + } + } + + if ( + await collections.orders.countDocuments( + { + 'notifications.paymentStatus.npub': npubAddress, + 'items.product._id': product._id, + 'payment.status': 'pending' + }, + { limit: 1 } + ) + ) { + throw error(400, 'You already have a pending order for this product: ' + product.name); + } + } + await withTransaction(async (session) => { const res = await collections.runtimeConfig.findOneAndUpdate( { _id: 'orderNumber' }, From cbdc2ba87d2b2954648581676804fb4ca91f630b Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Thu, 1 Jun 2023 23:38:12 +0200 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=94=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4180dd73a..eeb7c4783 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,8 +6,5 @@ }, "[svelte]": { "editor.defaultFormatter": "svelte.svelte-vscode" - }, - "[css]": { - "editor.defaultFormatter": "vscode.css-language-features" } } From 89219c5f064781ac017ee975713425ba731dc4e5 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Fri, 2 Jun 2023 00:18:17 +0200 Subject: [PATCH 06/11] =?UTF-8?q?=E2=9C=A8=20Handle=20subscription=20creat?= =?UTF-8?q?ion=20/=20NostR=20bot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/database.ts | 1 + src/lib/server/handle-messages.ts | 64 +++++++++++++++++++++++- src/lib/server/nostr-notifications.ts | 10 ++-- src/lib/server/order-lock.ts | 62 +++++++++++++---------- src/lib/server/orders.ts | 71 +++++++++++++++++++++++++++ src/lib/server/runtime-config.ts | 1 + src/lib/server/subscriptions.ts | 16 ++++++ src/lib/types/PaidSubscription.ts | 5 +- src/routes/checkout/+page.server.ts | 16 ++---- 9 files changed, 199 insertions(+), 47 deletions(-) create mode 100644 src/lib/server/orders.ts create mode 100644 src/lib/server/subscriptions.ts diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index e79e7d326..766ccf8f9 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -75,6 +75,7 @@ client.on('open', () => { { npub: 1, productId: 1 }, { unique: true, partialFilterExpression: { npub: { $exists: true } } } ); + paidSubscriptions.createIndex({ number: 1 }, { unique: true }); }); export async function withTransaction(cb: WithSessionCallback) { diff --git a/src/lib/server/handle-messages.ts b/src/lib/server/handle-messages.ts index f747d8e83..164ee1f95 100644 --- a/src/lib/server/handle-messages.ts +++ b/src/lib/server/handle-messages.ts @@ -36,7 +36,9 @@ async function handleChanges(change: ChangeStreamDocument) const send = (message: string) => sendMessage(senderNpub, message, minCreatedAt); - switch (content.trim().replaceAll(/\s+/g, ' ')) { + const toMatch = content.trim().replaceAll(/\s+/g, ' '); + + switch (toMatch) { case 'help': await send( `Commands: @@ -45,7 +47,9 @@ async function handleChanges(change: ChangeStreamDocument) - catalog: Show the catalog - detailed catalog: Show the catalog, with product descriptions - subscribe: Subscribe to catalog updates -- unsubscribe: Unsubscribe from catalog updates` +- unsubscribe: Unsubscribe from catalog updates +- subscriptions: Show the list of paid subscriptions associated to your npub +- cancel [subscription number]: Cancel a paid subscription to not be reminded anymore` ); break; case 'orders': { @@ -127,7 +131,63 @@ async function handleChanges(change: ChangeStreamDocument) } break; } + case 'subscriptions': { + const subscriptions = await collections.paidSubscriptions + .find({ npub: senderNpub, paidUntil: { $gt: new Date() } }) + .sort({ number: 1 }) + .toArray(); + + if (!subscriptions.length) { + await send('No active subscriptions found for your npub'); + } else { + await send( + subscriptions + .map( + (subscription) => + `- #${subscription.number}: ${ORIGIN}/subscription/${ + subscription._id + }, paid until ${subscription.paidUntil.toISOString()}${ + subscription.cancelledAt ? ' [cancelled]' : '' + }` + ) + .join('\n') + ); + } + + break; + } default: + if (toMatch.startsWith('cancel ')) { + const number = parseInt(toMatch.slice('cancel '.length), 10); + + if (isNaN(number)) { + await send('Invalid subscription number: ' + toMatch.slice('cancel '.length)); + break; + } + + const subscription = await collections.paidSubscriptions.findOne({ + npub: senderNpub, + number + }); + + if (!subscription) { + await send('No subscription found with number ' + number + ' for your npub'); + break; + } + + if (subscription.cancelledAt) { + await send('Subscription #' + number + ' was already cancelled'); + break; + } + + await collections.paidSubscriptions.updateOne( + { _id: subscription._id }, + { $set: { cancelledAt: new Date() } } + ); + + await send('Subscription #' + number + ' was cancelled, you will not be reminded anymore'); + break; + } await send( `Hello ${ !isPrivateMessage ? 'world' : isCustomer ? 'customer' : 'you' diff --git a/src/lib/server/nostr-notifications.ts b/src/lib/server/nostr-notifications.ts index 2f597717b..b1a550aca 100644 --- a/src/lib/server/nostr-notifications.ts +++ b/src/lib/server/nostr-notifications.ts @@ -3,7 +3,7 @@ import { Lock } from './lock'; import { processClosed } from './process'; import type { NostRNotification } from '$lib/types/NostRNotifications'; import { hexToNpub, nostrPrivateKeyHex, nostrPublicKeyHex, nostrRelays, nostrToHex } from './nostr'; -import { fromUnixTime, getUnixTime } from 'date-fns'; +import { fromUnixTime, getUnixTime, max } from 'date-fns'; import { collections } from './database'; import { RelayPool } from 'nostr-relaypool'; import { @@ -122,10 +122,10 @@ async function handleChanges(change: ChangeStreamDocument): P id: '', content: await nip04.encrypt(nostrPrivateKeyHex, receiverPublicKeyHex, content), created_at: getUnixTime( - change.fullDocument.minCreatedAt && - change.fullDocument.minCreatedAt > change.fullDocument.createdAt - ? change.fullDocument.minCreatedAt - : change.fullDocument.createdAt + max([ + change.fullDocument.minCreatedAt ?? change.fullDocument.createdAt, + change.fullDocument.createdAt + ]) ), pubkey: nostrPublicKeyHex, tags: [['p', receiverPublicKeyHex]], diff --git a/src/lib/server/order-lock.ts b/src/lib/server/order-lock.ts index 1a2796eee..6e32873d6 100644 --- a/src/lib/server/order-lock.ts +++ b/src/lib/server/order-lock.ts @@ -1,4 +1,4 @@ -import { collections } from './database'; +import { collections, withTransaction } from './database'; import { setTimeout } from 'node:timers/promises'; import { processClosed } from './process'; import type { Order } from '$lib/types/Order'; @@ -8,6 +8,7 @@ import { Lock } from './lock'; import { inspect } from 'node:util'; import { lndLookupInvoice } from './lightning'; import { toSatoshis } from '$lib/utils/toSatoshis'; +import { onOrderPaid } from './orders'; const lock = new Lock('orders'); @@ -20,7 +21,6 @@ async function maintainOrders() { const bitcoinOrders = await collections.orders .find({ 'payment.status': 'pending', 'payment.method': 'bitcoin' }) - .project>({ payment: 1, totalPrice: 1 }) .toArray() .catch((err) => { console.error(inspect(err, { depth: 10 })); @@ -38,20 +38,25 @@ async function maintainOrders() { const satReceived = toSatoshis(received, 'BTC'); if (satReceived >= toSatoshis(order.totalPrice.amount, order.totalPrice.currency)) { - await collections.orders.updateOne( - { _id: order._id }, - { - $set: { - 'payment.status': 'paid', - 'payment.paidAt': new Date(), - 'payment.transactions': transactions.map((transaction) => ({ - txid: transaction.txid, - amount: transaction.amount - })), - 'payment.totalReceived': received - } - } - ); + await withTransaction(async (session) => { + await collections.orders.updateOne( + { _id: order._id }, + { + $set: { + 'payment.status': 'paid', + 'payment.paidAt': new Date(), + 'payment.transactions': transactions.map((transaction) => ({ + txid: transaction.txid, + amount: transaction.amount + })), + 'payment.totalReceived': received + } + }, + { session } + ); + + await onOrderPaid(order, session); + }); } else if (order.payment.expiresAt < new Date()) { await collections.orders.updateOne( { _id: order._id }, @@ -80,16 +85,23 @@ async function maintainOrders() { const invoice = await lndLookupInvoice(order.payment.invoiceId); if (invoice.state === 'SETTLED') { - await collections.orders.updateOne( - { _id: order._id }, - { - $set: { - 'payment.status': 'paid', - 'payment.paidAt': invoice.settled_at, - 'payment.totalReceived': invoice.amt_paid_sat - } + await withTransaction(async (session) => { + const result = await collections.orders.findOneAndUpdate( + { _id: order._id }, + { + $set: { + 'payment.status': 'paid', + 'payment.paidAt': invoice.settled_at, + 'payment.totalReceived': invoice.amt_paid_sat + } + }, + { returnDocument: 'after' } + ); + if (!result.value) { + throw new Error('Failed to update order'); } - ); + await onOrderPaid(result.value, session); + }); } else if (invoice.state === 'CANCELED') { await collections.orders.updateOne( { _id: order._id }, diff --git a/src/lib/server/orders.ts b/src/lib/server/orders.ts new file mode 100644 index 000000000..bc4eb1113 --- /dev/null +++ b/src/lib/server/orders.ts @@ -0,0 +1,71 @@ +import type { Order } from '$lib/types/Order'; +import type { ClientSession } from 'mongodb'; +import { collections } from './database'; +import { add, max } from 'date-fns'; +import { runtimeConfig } from './runtime-config'; +import { generateSubscriptionNumber } from './subscriptions'; + +export async function generateOrderNumber(): Promise { + const res = await collections.runtimeConfig.findOneAndUpdate( + { _id: 'orderNumber' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { $inc: { data: 1 } as any }, + { upsert: true, returnDocument: 'after' } + ); + + if (!res.value) { + throw new Error('Failed to increment order number'); + } + + return res.value.data as number; +} + +export async function onOrderPaid(order: Order, session: ClientSession) { + if (order.notifications.paymentStatus.npub) { + const subscriptions = await collections.paidSubscriptions + .find({ + npub: order.notifications.paymentStatus.npub, + productId: { $in: order.items.map((item) => item.product._id) } + }) + .toArray(); + + for (const subscription of order.items.filter((item) => item.product.type === 'subscription')) { + const existingSubscription = subscriptions.find( + (sub) => sub.productId === subscription.product._id + ); + + if (existingSubscription) { + const result = await collections.paidSubscriptions.updateOne( + { _id: existingSubscription._id }, + { + $set: { + paidUntil: add(max([existingSubscription.paidUntil, new Date()]), { + [runtimeConfig.subscriptionDuration]: 1 + }), + updatedAt: new Date() + }, + $unset: { canceledAt: 1 } + }, + { session } + ); + + if (!result.modifiedCount) { + throw new Error('Failed to update subscription'); + } + } else { + await collections.paidSubscriptions.insertOne( + { + _id: crypto.randomUUID(), + number: await generateSubscriptionNumber(), + npub: order.notifications.paymentStatus.npub, + productId: subscription.product._id, + paidUntil: add(new Date(), { [runtimeConfig.subscriptionDuration]: 1 }), + createdAt: new Date(), + updatedAt: new Date() + }, + { session } + ); + } + } + } +} diff --git a/src/lib/server/runtime-config.ts b/src/lib/server/runtime-config.ts index 73e34d52f..80f8a7d8d 100644 --- a/src/lib/server/runtime-config.ts +++ b/src/lib/server/runtime-config.ts @@ -4,6 +4,7 @@ import { collections } from './database'; const defaultConfig = { BTC_EUR: 30_000, orderNumber: 0, + subscriptionNumber: 0, subscriptionDuration: 'month' as 'month' | 'day' | 'hour', subscriptionReminderSeconds: 24 * 60 * 60, diff --git a/src/lib/server/subscriptions.ts b/src/lib/server/subscriptions.ts new file mode 100644 index 000000000..c5cf011f7 --- /dev/null +++ b/src/lib/server/subscriptions.ts @@ -0,0 +1,16 @@ +import { collections } from './database'; + +export async function generateSubscriptionNumber(): Promise { + const res = await collections.runtimeConfig.findOneAndUpdate( + { _id: 'subscriptionNumber' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { $inc: { data: 1 } as any }, + { upsert: true, returnDocument: 'after' } + ); + + if (!res.value) { + throw new Error('Failed to increment subscription number'); + } + + return res.value.data as number; +} diff --git a/src/lib/types/PaidSubscription.ts b/src/lib/types/PaidSubscription.ts index e7de5a752..4b240e667 100644 --- a/src/lib/types/PaidSubscription.ts +++ b/src/lib/types/PaidSubscription.ts @@ -2,15 +2,16 @@ * For paid subscriptions */ -import type { ObjectId } from 'mongodb'; import type { Timestamps } from './Timestamps'; export interface PaidSubscription extends Timestamps { - _id: ObjectId; + _id: string; + number: number; npub: string; productId: string; paidUntil: Date; lastRemindedAt?: Date; + cancelledAt?: Date; } diff --git a/src/routes/checkout/+page.server.ts b/src/routes/checkout/+page.server.ts index 10bba3156..122443acb 100644 --- a/src/routes/checkout/+page.server.ts +++ b/src/routes/checkout/+page.server.ts @@ -10,6 +10,7 @@ import { bech32 } from 'bech32'; import { ORIGIN } from '$env/static/private'; import { toSatoshis } from '$lib/utils/toSatoshis.js'; import { runtimeConfig } from '$lib/server/runtime-config.js'; +import { generateOrderNumber } from '$lib/server/orders.js'; export function load() { return { @@ -148,20 +149,9 @@ export const actions = { } } - await withTransaction(async (session) => { - const res = await collections.runtimeConfig.findOneAndUpdate( - { _id: 'orderNumber' }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { $inc: { data: 1 } as any }, - { upsert: true, session, returnDocument: 'after' } - ); - - if (!res.value) { - throw new Error('Failed to increment order number'); - } - - const orderNumber = res.value.data as number; + const orderNumber = await generateOrderNumber(); + await withTransaction(async (session) => { const expiresAt = addHours(new Date(), 2); await collections.orders.insertOne( From af09ecd9c20da932ffd78a66d6cff94fc278d6b8 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Fri, 2 Jun 2023 18:18:56 +0200 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=9A=9A=20Move=20locks=20to=20specif?= =?UTF-8?q?ic=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks.server.ts | 6 +----- src/lib/server/{ => locks}/currency-lock.ts | 6 +++--- src/lib/server/{ => locks}/handle-messages.ts | 6 +++--- src/lib/server/locks/index.ts | 6 ++++++ .../server/{ => locks}/nostr-notifications.ts | 14 ++++++++++---- src/lib/server/{ => locks}/order-lock.ts | 12 ++++++------ .../server/{ => locks}/order-notifications.ts | 4 ++-- src/lib/server/locks/subscription-lock.ts | 18 ++++++++++++++++++ 8 files changed, 49 insertions(+), 23 deletions(-) rename src/lib/server/{ => locks}/currency-lock.ts (90%) rename src/lib/server/{ => locks}/handle-messages.ts (97%) create mode 100644 src/lib/server/locks/index.ts rename src/lib/server/{ => locks}/nostr-notifications.ts (94%) rename src/lib/server/{ => locks}/order-lock.ts (91%) rename src/lib/server/{ => locks}/order-notifications.ts (95%) create mode 100644 src/lib/server/locks/subscription-lock.ts diff --git a/src/hooks.server.ts b/src/hooks.server.ts index f66d1c77d..16404df3b 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,11 +4,7 @@ import { collections } from '$lib/server/database'; import { ObjectId } from 'mongodb'; import { addYears } from 'date-fns'; -import '$lib/server/currency-lock'; -import '$lib/server/order-lock'; -import '$lib/server/order-notifications'; -import '$lib/server/nostr-notifications'; -import '$lib/server/handle-messages'; +import '$lib/server/locks'; export const handleError = (({ error, event }) => { console.error('handleError', error); diff --git a/src/lib/server/currency-lock.ts b/src/lib/server/locks/currency-lock.ts similarity index 90% rename from src/lib/server/currency-lock.ts rename to src/lib/server/locks/currency-lock.ts index e0f848525..a361db6cc 100644 --- a/src/lib/server/currency-lock.ts +++ b/src/lib/server/locks/currency-lock.ts @@ -1,8 +1,8 @@ -import { collections } from './database'; +import { collections } from '../database'; import { differenceInMinutes } from 'date-fns'; import { setTimeout } from 'node:timers/promises'; -import { processClosed } from './process'; -import { Lock } from './lock'; +import { processClosed } from '../process'; +import { Lock } from '../lock'; const lock = new Lock('currency'); diff --git a/src/lib/server/handle-messages.ts b/src/lib/server/locks/handle-messages.ts similarity index 97% rename from src/lib/server/handle-messages.ts rename to src/lib/server/locks/handle-messages.ts index 164ee1f95..862aa7bd6 100644 --- a/src/lib/server/handle-messages.ts +++ b/src/lib/server/locks/handle-messages.ts @@ -1,10 +1,10 @@ import { ObjectId, type ChangeStreamDocument } from 'mongodb'; -import { collections } from './database'; -import { Lock } from './lock'; +import { collections } from '../database'; +import { Lock } from '../lock'; import type { NostRReceivedMessage } from '$lib/types/NostRReceivedMessage'; import { Kind } from 'nostr-tools'; import { ORIGIN } from '$env/static/private'; -import { runtimeConfig } from './runtime-config'; +import { runtimeConfig } from '../runtime-config'; import { toSatoshis } from '$lib/utils/toSatoshis'; import { addSeconds } from 'date-fns'; diff --git a/src/lib/server/locks/index.ts b/src/lib/server/locks/index.ts new file mode 100644 index 000000000..8bf90da67 --- /dev/null +++ b/src/lib/server/locks/index.ts @@ -0,0 +1,6 @@ +import './currency-lock'; +import './order-lock'; +import './order-notifications'; +import './nostr-notifications'; +import './handle-messages'; +import './subscription-lock'; diff --git a/src/lib/server/nostr-notifications.ts b/src/lib/server/locks/nostr-notifications.ts similarity index 94% rename from src/lib/server/nostr-notifications.ts rename to src/lib/server/locks/nostr-notifications.ts index b1a550aca..232418ef2 100644 --- a/src/lib/server/nostr-notifications.ts +++ b/src/lib/server/locks/nostr-notifications.ts @@ -1,10 +1,16 @@ import type { ChangeStreamDocument } from 'mongodb'; -import { Lock } from './lock'; -import { processClosed } from './process'; +import { Lock } from '../lock'; +import { processClosed } from '../process'; import type { NostRNotification } from '$lib/types/NostRNotifications'; -import { hexToNpub, nostrPrivateKeyHex, nostrPublicKeyHex, nostrRelays, nostrToHex } from './nostr'; +import { + hexToNpub, + nostrPrivateKeyHex, + nostrPublicKeyHex, + nostrRelays, + nostrToHex +} from '../nostr'; import { fromUnixTime, getUnixTime, max } from 'date-fns'; -import { collections } from './database'; +import { collections } from '../database'; import { RelayPool } from 'nostr-relaypool'; import { getEventHash, diff --git a/src/lib/server/order-lock.ts b/src/lib/server/locks/order-lock.ts similarity index 91% rename from src/lib/server/order-lock.ts rename to src/lib/server/locks/order-lock.ts index 6e32873d6..149004fd5 100644 --- a/src/lib/server/order-lock.ts +++ b/src/lib/server/locks/order-lock.ts @@ -1,14 +1,14 @@ -import { collections, withTransaction } from './database'; +import { collections, withTransaction } from '../database'; import { setTimeout } from 'node:timers/promises'; -import { processClosed } from './process'; +import { processClosed } from '../process'; import type { Order } from '$lib/types/Order'; -import { listTransactions, orderAddressLabel } from './bitcoin'; +import { listTransactions, orderAddressLabel } from '../bitcoin'; import { sum } from '$lib/utils/sum'; -import { Lock } from './lock'; +import { Lock } from '../lock'; import { inspect } from 'node:util'; -import { lndLookupInvoice } from './lightning'; +import { lndLookupInvoice } from '../lightning'; import { toSatoshis } from '$lib/utils/toSatoshis'; -import { onOrderPaid } from './orders'; +import { onOrderPaid } from '../orders'; const lock = new Lock('orders'); diff --git a/src/lib/server/order-notifications.ts b/src/lib/server/locks/order-notifications.ts similarity index 95% rename from src/lib/server/order-notifications.ts rename to src/lib/server/locks/order-notifications.ts index 7fb5f79ac..5dd444244 100644 --- a/src/lib/server/order-notifications.ts +++ b/src/lib/server/locks/order-notifications.ts @@ -1,7 +1,7 @@ import type { Order } from '$lib/types/Order'; import { ObjectId, type ChangeStreamDocument } from 'mongodb'; -import { collections } from './database'; -import { Lock } from './lock'; +import { collections } from '../database'; +import { Lock } from '../lock'; import { ORIGIN } from '$env/static/private'; const lock = new Lock('order-notifications'); diff --git a/src/lib/server/locks/subscription-lock.ts b/src/lib/server/locks/subscription-lock.ts new file mode 100644 index 000000000..a9ece2399 --- /dev/null +++ b/src/lib/server/locks/subscription-lock.ts @@ -0,0 +1,18 @@ +import { Lock } from '../lock'; +import { processClosed } from '../process'; +import { setTimeout } from 'node:timers/promises'; + +const lock = new Lock('paid-subscriptions'); + +async function maintainLock() { + while (!processClosed) { + if (!lock.ownsLock) { + await setTimeout(5_000); + continue; + } + + await setTimeout(5_000); + } +} + +maintainLock(); From 03f2de1383b7e5c45a17572493792a5846db479c Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Fri, 2 Jun 2023 20:16:29 +0200 Subject: [PATCH 08/11] =?UTF-8?q?=E2=9C=A8=20Send=20notification=20reminde?= =?UTF-8?q?rs=20for=20subscriptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/database.ts | 48 ++++++----- src/lib/server/locks/subscription-lock.ts | 99 +++++++++++++++++++++++ src/lib/server/orders.ts | 8 +- src/lib/types/PaidSubscription.ts | 9 ++- 4 files changed, 142 insertions(+), 22 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 766ccf8f9..41e6ad79e 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -58,24 +58,36 @@ export function transaction(dbTransactions: WithSessionCallback): Promise } client.on('open', () => { - pictures.createIndex({ productId: 1 }); - locks.createIndex({ updatedAt: 1 }, { expireAfterSeconds: 60 }); - carts.createIndex({ sessionId: 1 }, { unique: true }); - orders.createIndex({ sessionId: 1 }); - orders.createIndex( - { 'notifications.paymentStatus.npub': 1, createdAt: -1 }, - { partialFilterExpression: { 'notifications.paymentStatus.npub': { $exists: true } } } - ); - orders.createIndex({ number: 1 }, { unique: true }); - digitalFiles.createIndex({ productId: 1 }); - nostrReceivedMessages.createIndex({ createdAt: -1 }); - nostrNotifications.createIndex({ dest: 1 }); - bootikSubscriptions.createIndex({ npub: 1 }, { sparse: true }); - paidSubscriptions.createIndex( - { npub: 1, productId: 1 }, - { unique: true, partialFilterExpression: { npub: { $exists: true } } } - ); - paidSubscriptions.createIndex({ number: 1 }, { unique: true }); + pictures.createIndex({ productId: 1 }).catch(console.error); + locks.createIndex({ updatedAt: 1 }, { expireAfterSeconds: 60 }).catch(console.error); + carts.createIndex({ sessionId: 1 }, { unique: true }).catch(console.error); + orders.createIndex({ sessionId: 1 }).catch(console.error); + orders + .createIndex( + { 'notifications.paymentStatus.npub': 1, createdAt: -1 }, + { partialFilterExpression: { 'notifications.paymentStatus.npub': { $exists: true } } } + ) + .catch(console.error); + orders.createIndex({ number: 1 }, { unique: true }).catch(console.error); + digitalFiles.createIndex({ productId: 1 }).catch(console.error); + nostrReceivedMessages.createIndex({ createdAt: -1 }).catch(console.error); + nostrNotifications.createIndex({ dest: 1 }).catch(console.error); + bootikSubscriptions.createIndex({ npub: 1 }, { sparse: true }).catch(console.error); + paidSubscriptions + .createIndex( + { npub: 1, productId: 1 }, + { unique: true, partialFilterExpression: { npub: { $exists: true } } } + ) + .catch(console.error); + paidSubscriptions.createIndex({ number: 1 }, { unique: true }).catch(console.error); + // See subscription-lock.ts, for searching for subscriptions to remind + // todo: find which index is better + paidSubscriptions + .createIndex({ cancelledAt: 1, paidUntil: 1, 'notifications.type': 1 }) + .catch(console.error); + paidSubscriptions + .createIndex({ cancelledAt: 1, 'notifications.type': 1, paidUntil: 1 }) + .catch(console.error); }); export async function withTransaction(cb: WithSessionCallback) { diff --git a/src/lib/server/locks/subscription-lock.ts b/src/lib/server/locks/subscription-lock.ts index a9ece2399..cfffb980c 100644 --- a/src/lib/server/locks/subscription-lock.ts +++ b/src/lib/server/locks/subscription-lock.ts @@ -1,6 +1,11 @@ +import { addSeconds, formatDistance } from 'date-fns'; +import { collections, withTransaction } from '../database'; import { Lock } from '../lock'; import { processClosed } from '../process'; import { setTimeout } from 'node:timers/promises'; +import { runtimeConfig } from '../runtime-config'; +import { ObjectId } from 'mongodb'; +import { ORIGIN } from '$env/static/private'; const lock = new Lock('paid-subscriptions'); @@ -11,6 +16,100 @@ async function maintainLock() { continue; } + try { + const subscriptionsToRemind = collections.paidSubscriptions.find({ + paidUntil: { + $gt: new Date(), + $lt: addSeconds(new Date(), runtimeConfig.subscriptionReminderSeconds) + }, + cancelledAt: { $exists: false }, + 'notifications.type': { $ne: 'reminder' } + }); + + for await (const subscription of subscriptionsToRemind) { + await withTransaction(async (session) => { + const notifId = new ObjectId(); + + await collections.nostrNotifications.insertOne( + { + _id: notifId, + dest: subscription.npub, + content: `Your subscription #${ + subscription.number + } is going to expire ${formatDistance(subscription.paidUntil, new Date(), { + addSuffix: true + })}. Renew here: ${ORIGIN}/subscription/${subscription._id}`, + createdAt: new Date(), + updatedAt: new Date() + }, + { session } + ); + + await collections.paidSubscriptions.updateOne( + { + _id: subscription._id + }, + { + $push: { + notifications: { + createdAt: new Date(), + _id: notifId, + type: 'reminder' + } + } + }, + { session } + ); + }).catch(console.error); + } + } catch (err) { + console.error(err); + } + + try { + const subscriptionsToNotifyEnd = collections.paidSubscriptions.find({ + paidUntil: { + $lt: new Date() + }, + cancelledAt: { $exists: false }, + 'notifications.type': { $ne: 'expiration' } + }); + + for await (const subscription of subscriptionsToNotifyEnd) { + await withTransaction(async (session) => { + const notifId = new ObjectId(); + await collections.nostrNotifications.insertOne( + { + _id: notifId, + dest: subscription.npub, + content: `Your subscription #${subscription.number} expired. Renew here if you wish: ${ORIGIN}/subscription/${subscription._id}`, + createdAt: new Date(), + updatedAt: new Date() + }, + { session } + ); + + await collections.paidSubscriptions.updateOne( + { + _id: subscription._id + }, + { + $push: { + notifications: { + createdAt: new Date(), + _id: notifId, + type: 'expiration' + } + } + }, + { session } + ); + }).catch(console.error); + } + } catch (err) { + console.error(err); + } + await setTimeout(5_000); } } diff --git a/src/lib/server/orders.ts b/src/lib/server/orders.ts index bc4eb1113..682451f2c 100644 --- a/src/lib/server/orders.ts +++ b/src/lib/server/orders.ts @@ -42,9 +42,10 @@ export async function onOrderPaid(order: Order, session: ClientSession) { paidUntil: add(max([existingSubscription.paidUntil, new Date()]), { [runtimeConfig.subscriptionDuration]: 1 }), - updatedAt: new Date() + updatedAt: new Date(), + notifications: {} }, - $unset: { canceledAt: 1 } + $unset: { cancelledAt: 1 } }, { session } ); @@ -61,7 +62,8 @@ export async function onOrderPaid(order: Order, session: ClientSession) { productId: subscription.product._id, paidUntil: add(new Date(), { [runtimeConfig.subscriptionDuration]: 1 }), createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + notifications: {} }, { session } ); diff --git a/src/lib/types/PaidSubscription.ts b/src/lib/types/PaidSubscription.ts index 4b240e667..b893fede7 100644 --- a/src/lib/types/PaidSubscription.ts +++ b/src/lib/types/PaidSubscription.ts @@ -2,6 +2,7 @@ * For paid subscriptions */ +import type { ObjectId } from 'mongodb'; import type { Timestamps } from './Timestamps'; export interface PaidSubscription extends Timestamps { @@ -12,6 +13,12 @@ export interface PaidSubscription extends Timestamps { productId: string; paidUntil: Date; - lastRemindedAt?: Date; + + notifications: Array<{ + type: 'reminder' | 'expiration'; + createdAt: Date; + _id: ObjectId; + }>; + cancelledAt?: Date; } From b164c6a71925a87f86afab1cabe1fc75e5683b06 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Fri, 2 Jun 2023 21:09:55 +0200 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=9A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/orders.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/server/orders.ts b/src/lib/server/orders.ts index 682451f2c..5684f1235 100644 --- a/src/lib/server/orders.ts +++ b/src/lib/server/orders.ts @@ -43,7 +43,7 @@ export async function onOrderPaid(order: Order, session: ClientSession) { [runtimeConfig.subscriptionDuration]: 1 }), updatedAt: new Date(), - notifications: {} + notifications: [] }, $unset: { cancelledAt: 1 } }, @@ -63,7 +63,7 @@ export async function onOrderPaid(order: Order, session: ClientSession) { paidUntil: add(new Date(), { [runtimeConfig.subscriptionDuration]: 1 }), createdAt: new Date(), updatedAt: new Date(), - notifications: {} + notifications: [] }, { session } ); From afe78424970463df715848d57cb8568e5d24f56e Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Fri, 2 Jun 2023 23:57:03 +0200 Subject: [PATCH 10/11] =?UTF-8?q?=E2=9C=A8=20Add=20subscription=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/components/ProductItem.svelte | 17 +++ src/lib/server/orders.ts | 143 ++++++++++++++++++- src/routes/+page.svelte | 11 +- src/routes/admin/product/+page.svelte | 11 +- src/routes/checkout/+page.server.ts | 143 ++----------------- src/routes/subscription/[id]/+page.server.ts | 86 +++++++++++ src/routes/subscription/[id]/+page.svelte | 26 ++++ 7 files changed, 288 insertions(+), 149 deletions(-) create mode 100644 src/lib/components/ProductItem.svelte create mode 100644 src/routes/subscription/[id]/+page.server.ts create mode 100644 src/routes/subscription/[id]/+page.svelte diff --git a/src/lib/components/ProductItem.svelte b/src/lib/components/ProductItem.svelte new file mode 100644 index 000000000..e6c9b5202 --- /dev/null +++ b/src/lib/components/ProductItem.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/server/orders.ts b/src/lib/server/orders.ts index 5684f1235..8c094c75d 100644 --- a/src/lib/server/orders.ts +++ b/src/lib/server/orders.ts @@ -1,11 +1,17 @@ import type { Order } from '$lib/types/Order'; import type { ClientSession } from 'mongodb'; -import { collections } from './database'; -import { add, max } from 'date-fns'; +import { collections, withTransaction } from './database'; +import { add, addHours, differenceInSeconds, max, subSeconds } from 'date-fns'; import { runtimeConfig } from './runtime-config'; import { generateSubscriptionNumber } from './subscriptions'; +import type { Product } from '$lib/types/Product'; +import { error } from '@sveltejs/kit'; +import { toSatoshis } from '$lib/utils/toSatoshis'; +import { getNewAddress, orderAddressLabel } from './bitcoin'; +import { lndCreateInvoice } from './lightning'; +import { ORIGIN } from '$env/static/private'; -export async function generateOrderNumber(): Promise { +async function generateOrderNumber(): Promise { const res = await collections.runtimeConfig.findOneAndUpdate( { _id: 'orderNumber' }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -71,3 +77,134 @@ export async function onOrderPaid(order: Order, session: ClientSession) { } } } + +export async function createOrder( + items: Array<{ quantity: number; product: Product }>, + paymentMethod: Order['payment']['method'], + params: { + sessionId: string; + npub: string; + shippingAddress: Order['shippingAddress'] | null; + cb?: (session: ClientSession) => Promise; + } +): Promise { + const products = items.map((item) => item.product); + if ( + products.some( + (product) => product.availableDate && !product.preorder && product.availableDate > new Date() + ) + ) { + throw error(400, 'Cart contains products that are not yet available'); + } + + const isDigital = products.every((product) => !product.shipping); + + if (!isDigital && !params.shippingAddress) { + throw error(400, 'Shipping address is required'); + } + + let totalSatoshis = 0; + + for (const item of items) { + const price = parseFloat(item.product.price.amount.toString()); + const quantity = item.quantity; + + totalSatoshis += toSatoshis(price * quantity, item.product.price.currency); + } + + const orderId = crypto.randomUUID(); + + const subscriptions = items.filter((item) => item.product.type === 'subscription'); + + for (const subscription of subscriptions) { + const product = subscription.product; + + if (subscription.quantity > 1) { + throw error( + 400, + 'Cannot order more than one of a subscription at a time for product: ' + product.name + ); + } + + const existingSubscription = await collections.paidSubscriptions.findOne({ + npub: params.npub, + productId: product._id + }); + + if (existingSubscription) { + if ( + subSeconds(existingSubscription.paidUntil, runtimeConfig.subscriptionReminderSeconds) > + new Date() + ) { + throw error( + 400, + 'You already have an active subscription for this product: ' + product.name + ); + } + } + + if ( + await collections.orders.countDocuments( + { + 'notifications.paymentStatus.npub': params.npub, + 'items.product._id': product._id, + 'payment.status': 'pending' + }, + { limit: 1 } + ) + ) { + throw error(400, 'You already have a pending order for this product: ' + product.name); + } + } + + const orderNumber = await generateOrderNumber(); + + await withTransaction(async (session) => { + const expiresAt = addHours(new Date(), 2); + + await collections.orders.insertOne( + { + _id: orderId, + number: orderNumber, + sessionId: params.sessionId, + createdAt: new Date(), + updatedAt: new Date(), + items, + ...(params.shippingAddress && { shippingAddress: params.shippingAddress }), + totalPrice: { + amount: totalSatoshis, + currency: 'SAT' + }, + payment: { + method: paymentMethod, + status: 'pending', + ...(paymentMethod === 'bitcoin' + ? { address: await getNewAddress(orderAddressLabel(orderId)) } + : await (async () => { + const invoice = await lndCreateInvoice( + totalSatoshis, + differenceInSeconds(expiresAt, new Date()), + `${ORIGIN}/order/${orderId}` + ); + + return { + address: invoice.payment_request, + invoiceId: invoice.r_hash + }; + })()), + expiresAt + }, + notifications: { + paymentStatus: { + npub: params.npub + } + } + }, + { session } + ); + + await params.cb?.(session); + }); + + return orderId; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 41e647c28..5f113f877 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,5 @@ + +
+

+ Subscription {data.subscription.number} +

+ + + +

+ Paid until: + {#if data.subscription.paidUntil < new Date()} + (expired) + {/if} +

+ +
+ +
+
From f16ee9dbaef3e940325b3c65457d6b7561cf9608 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Sat, 3 Jun 2023 00:02:20 +0200 Subject: [PATCH 11/11] =?UTF-8?q?=E2=9C=A8=20Disable=20renew=20button=20if?= =?UTF-8?q?=20subscription=20not=20due=20for=20renewal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/orders.ts | 4 +++- src/routes/subscription/[id]/+page.server.ts | 10 +++++++++- src/routes/subscription/[id]/+page.svelte | 6 +++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/lib/server/orders.ts b/src/lib/server/orders.ts index 8c094c75d..cc3746fb2 100644 --- a/src/lib/server/orders.ts +++ b/src/lib/server/orders.ts @@ -138,7 +138,9 @@ export async function createOrder( ) { throw error( 400, - 'You already have an active subscription for this product: ' + product.name + 'You already have an active subscription for this product: ' + + product.name + + ', which is not due for renewal yet.' ); } } diff --git a/src/routes/subscription/[id]/+page.server.ts b/src/routes/subscription/[id]/+page.server.ts index a0f5be963..7175c8a05 100644 --- a/src/routes/subscription/[id]/+page.server.ts +++ b/src/routes/subscription/[id]/+page.server.ts @@ -1,6 +1,8 @@ import { collections } from '$lib/server/database.js'; import { createOrder } from '$lib/server/orders.js'; +import { runtimeConfig } from '$lib/server/runtime-config.js'; import { error, redirect } from '@sveltejs/kit'; +import { subSeconds } from 'date-fns'; export async function load({ params }) { const subscription = await collections.paidSubscriptions.findOne({ @@ -26,10 +28,16 @@ export async function load({ params }) { { sort: { createdAt: 1 } } ); + const canRenewAfter = subSeconds( + subscription.paidUntil, + runtimeConfig.subscriptionReminderSeconds + ); + return { subscription, product, - picture: picture ?? undefined + picture: picture ?? undefined, + canRenew: canRenewAfter < new Date() }; } diff --git a/src/routes/subscription/[id]/+page.svelte b/src/routes/subscription/[id]/+page.svelte index 4f1cfdd8c..f519c18a2 100644 --- a/src/routes/subscription/[id]/+page.svelte +++ b/src/routes/subscription/[id]/+page.svelte @@ -21,6 +21,10 @@

- +