From 234b14bf42112c3ecd430158f68c2e2d04890378 Mon Sep 17 00:00:00 2001 From: Krrish Sehgal <133865424+krrish-sehgal@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:29:54 +0530 Subject: [PATCH 1/3] add: end-date in recurring payments --- README.md | 1 + app/api/subscriptions/route.ts | 3 +++ .../components/ConfirmSubscriptionForm.tsx | 3 +++ .../components/SubscriptionSummary.tsx | 7 ++++++ app/confirm/page.tsx | 3 +++ .../components/CreateSubscriptionForm.tsx | 22 +++++++++++++++++++ app/subscriptions/[id]/page.tsx | 1 + .../migration.sql | 2 ++ pages/api/inngest.ts | 10 +++++++++ schema.prisma | 1 + types/CreateSubscriptionRequest.ts | 1 + 11 files changed, 54 insertions(+) create mode 100644 migrations/20250926162316_add_end_date_time_field/migration.sql diff --git a/README.md b/README.md index 5cd2558..4c46f14 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ https://zapplanner.albylabs.com/confirm?amount=21&recipient=hello@getalby.com&ti - `comment` and `payerdata` will only be sent if the recipient lightning address supports it - `returnUrl` encoded URL to show as link on confirmation page - `nwcUrl` a url-encoded NWC connection secret +- `endDateTime` (optional) ISO 8601 date string when payments should stop e.g. `2025-12-31T23:59:59.000Z` ## API diff --git a/app/api/subscriptions/route.ts b/app/api/subscriptions/route.ts index d725ccc..17a373b 100644 --- a/app/api/subscriptions/route.ts +++ b/app/api/subscriptions/route.ts @@ -115,6 +115,9 @@ export async function POST(request: Request) { sleepDuration, sleepDurationMs, cronExpression, + endDateTime: createSubscriptionRequest.endDateTime + ? new Date(createSubscriptionRequest.endDateTime) + : null, }, }); diff --git a/app/confirm/components/ConfirmSubscriptionForm.tsx b/app/confirm/components/ConfirmSubscriptionForm.tsx index 1b70099..0e2dcef 100644 --- a/app/confirm/components/ConfirmSubscriptionForm.tsx +++ b/app/confirm/components/ConfirmSubscriptionForm.tsx @@ -100,6 +100,9 @@ export function ConfirmSubscriptionForm({ nextCronExecution, message: unconfirmedSubscription.message, payerData: unconfirmedSubscription.payerData, + endDateTime: unconfirmedSubscription.endDateTime + ? new Date(unconfirmedSubscription.endDateTime) + : undefined, }} showFirstPayment /> diff --git a/app/confirm/components/SubscriptionSummary.tsx b/app/confirm/components/SubscriptionSummary.tsx index 3b9c706..12bc991 100644 --- a/app/confirm/components/SubscriptionSummary.tsx +++ b/app/confirm/components/SubscriptionSummary.tsx @@ -19,6 +19,7 @@ type SubscriptionSummaryProps = { numFailedPayments?: number; retryCount?: number; payerData?: string; + endDateTime?: Date; }; showFirstPayment?: boolean; }; @@ -120,6 +121,12 @@ export function SubscriptionSummary({ } /> )} + {values.endDateTime && ( + + )} ; }; @@ -34,6 +35,7 @@ export default async function ConfirmSubscriptionPage({ returnUrl, nwcUrl, currency, + endDateTime, } = await searchParams; if (!amount || !recipient || (!timeframe && !cron)) { @@ -48,6 +50,7 @@ export default async function ConfirmSubscriptionPage({ message: comment ? decodeURIComponent(comment) : undefined, payerData: payerdata ? decodeURIComponent(payerdata) : undefined, currency, + endDateTime: endDateTime, }; return ( diff --git a/app/create/components/CreateSubscriptionForm.tsx b/app/create/components/CreateSubscriptionForm.tsx index c426a16..588b3e2 100644 --- a/app/create/components/CreateSubscriptionForm.tsx +++ b/app/create/components/CreateSubscriptionForm.tsx @@ -111,6 +111,9 @@ export function CreateSubscriptionForm() { if (encodedPayerData) { searchParams.append("payerdata", encodeURIComponent(encodedPayerData)); } + if (data.endDateTime) { + searchParams.append("endDateTime", data.endDateTime); + } setNavigating(true); push(`/confirm?${searchParams.toString()}`); }); @@ -385,6 +388,25 @@ export function CreateSubscriptionForm() { )} + + + { + if (value && new Date(value) <= new Date()) { + return "End date must be in the future"; + } + return undefined; + }, + })} + type="datetime-local" + className="zp-input" + min={new Date().toISOString().slice(0, 16)} + placeholder="Leave empty for unlimited payments" + /> + {errors.endDateTime && ( +

{errors.endDateTime.message}

+ )} + + + {isOpen && ( + <> + {/* Cron Expression Toggle */} +
+ + +
+ + {useCron && ( +
+ + { + if (!value) return "Cron expression is required"; + const parts = value.split(" "); + if (parts.length !== 5) { + return "Cron expression must have 5 parts (minute hour day month weekday)"; + } + if ( + process.env.NEXT_PUBLIC_ALLOW_SHORT_TIMEFRAMES !== + "true" && + !/^[0-5]?[0-9] /.test(value) + ) { + return "Cron expression must repeat only once per hour"; + } + }, + })} + className="zp-input" + placeholder="0 10 * * 0 (Every Sunday at 10:00 AM UTC)" + /> + {errors.cronExpression && ( +

+ {errors.cronExpression.message as string} +

+ )} +
+

Examples:

+
    +
  • + 0 10 * * 0 - Every Sunday at 10:00 AM UTC +
  • +
  • + 0 23 * * 0 - Every Sunday at 11:00 PM UTC +
  • +
  • + 0 9 * * 1 - Every Monday at 9:00 AM UTC +
  • +
  • + 0 12 * * * - Every day at 12:00 PM UTC +
  • +
  • + 0 0 1 * * - First day of every month at + midnight UTC +
  • +
  • + 0 0 * * 1#1 - First Monday of every month at + midnight UTC +
  • +
+

+ + 📅 Use crontab.guru for help + +

+
+
+ )} + + {/* Max Payments */} + + { + if (!value) return undefined; + if (!Number.isInteger(Number(value))) { + return "Please enter a whole number"; + } + if (Number(value) <= 0) { + return "Please enter a positive number"; + } + return undefined; + }, + })} + type="number" + min="1" + className="zp-input" + placeholder="e.g., 12 (leave empty for unlimited)" + /> + {errors.maxPayments && ( +

+ {errors.maxPayments.message as string} +

+ )} + + {/* End DateTime */} + + { + if (!value) return undefined; + if (new Date(value) <= new Date()) { + return "End date must be in the future"; + } + return undefined; + }, + })} + type="datetime-local" + className="zp-input" + min={new Date().toISOString().slice(0, 16)} + placeholder="Leave empty for unlimited payments" + /> + {errors.endDateTime && ( +

+ {errors.endDateTime.message as string} +

+ )} + + )} + + ); +} diff --git a/app/create/components/CreateSubscriptionForm.tsx b/app/create/components/CreateSubscriptionForm.tsx index 6f89f59..409af96 100644 --- a/app/create/components/CreateSubscriptionForm.tsx +++ b/app/create/components/CreateSubscriptionForm.tsx @@ -15,6 +15,8 @@ import { fiat, } from "@getalby/lightning-tools"; import { SATS_CURRENCY } from "lib/constants"; +import { FrequencySelector } from "./FrequencySelector"; +import { AdvancedOptions } from "./AdvancedOptions"; type CreateSubscriptionFormData = Omit< CreateSubscriptionRequest, @@ -258,117 +260,14 @@ export function CreateSubscriptionForm() { {errors.amount && (

{errors.amount.message}

)} - -
- - -
- - {watch("useCron") ? ( -
- - { - if (!value) return "Cron expression is required"; - const parts = value.split(" "); - if (parts.length !== 5) { - return "Cron expression must have 5 parts (minute hour day month weekday)"; - } - if ( - process.env.NEXT_PUBLIC_ALLOW_SHORT_TIMEFRAMES !== - "true" && - !/^[0-5]?[0-9] /.test(value) - ) { - return "Cron expression must repeat only once per hour"; - } - }, - })} - className="zp-input" - placeholder="0 10 * * 0 (Every Sunday at 10:00 AM UTC)" - /> - {errors.cronExpression && ( -

{errors.cronExpression.message}

- )} -
-

Examples:

-
    -
  • - 0 10 * * 0 - Every Sunday at 10:00 AM UTC -
  • -
  • - 0 23 * * 0 - Every Sunday at 11:00 PM UTC -
  • -
  • - 0 9 * * 1 - Every Monday at 9:00 AM UTC -
  • -
  • - 0 12 * * * - Every day at 12:00 PM UTC -
  • -
  • - 0 0 1 * * - First day of every month at - midnight UTC -
  • -
  • - 0 0 * * 1#1 - First Monday of every month at - midnight UTC -
  • -
-

- - 📅 Use crontab.guru for help - -

-
-
- ) : ( - <> -
-

Repeat payment every

- - !isValidPositiveValue(parseInt(value)) - ? "Please enter a positive value" - : undefined, - })} - className={`zp-input w-full`} - /> - -
- {errors.timeframeValue && ( -

{errors.timeframeValue.message}

- )} - )} {lightningAddress && @@ -392,48 +291,11 @@ export function CreateSubscriptionForm() { )} - - { - if (value && !Number.isInteger(Number(value))) { - return "Please enter a whole number"; - } - if (value && Number(value) <= 0) { - return "Please enter a positive number"; - } - return undefined; - }, - })} - type="number" - min="1" - className="zp-input" - placeholder="e.g., 12 (leave empty for unlimited)" - /> - {errors.maxPayments && ( -

{errors.maxPayments.message}

- )} - - - { - if (value && new Date(value) <= new Date()) { - return "End date must be in the future"; - } - return undefined; - }, - })} - type="datetime-local" - className="zp-input" - min={new Date().toISOString().slice(0, 16)} - placeholder="Leave empty for unlimited payments" + - {errors.endDateTime && ( -

{errors.endDateTime.message}

- )}